feat: harden work-record and SBOM client contracts
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
2577379e36
commit
84952c5212
16 changed files with 605 additions and 30 deletions
|
|
@ -347,12 +347,18 @@ def main(argv: list[str] | None = None) -> int:
|
|||
help="Deprecated compatibility commands delegated to SBOM Nexus",
|
||||
)
|
||||
sbom_sub = p_sbom.add_subparsers(dest="sbom_command")
|
||||
p_sbom_scan = sbom_sub.add_parser("scan", help="Scan recognised lockfiles and tool manifests")
|
||||
p_sbom_scan = sbom_sub.add_parser(
|
||||
"scan",
|
||||
help="Run a local non-authoritative preview through the SBOM Nexus CLI",
|
||||
)
|
||||
p_sbom_scan.add_argument("--path", default=".")
|
||||
p_sbom_scan.add_argument("--slug", default=None)
|
||||
p_sbom_scan.add_argument("--output", default=None, help="Write the derived snapshot as JSON")
|
||||
p_sbom_scan.add_argument("--force", action="store_true", help="Replace an existing --output file")
|
||||
p_sbom_report = sbom_sub.add_parser("licence-report", help="Report licences from a fresh file scan")
|
||||
p_sbom_report = sbom_sub.add_parser(
|
||||
"licence-report",
|
||||
help="Preview licences locally without persisting or advancing Nexus state",
|
||||
)
|
||||
p_sbom_report.add_argument("--path", default=".")
|
||||
p_sbom_report.add_argument("--slug", default=None)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import httpx
|
|||
from repo_manager.gitops import GitError, commit_paths, push_ff
|
||||
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||
from repo_manager.parse.workplan import parse_workplan_file
|
||||
from repo_manager.record_identity import scan_record_identities
|
||||
|
||||
LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock")
|
||||
|
||||
|
|
@ -379,14 +380,27 @@ def registrar_reconcile(
|
|||
"""Register missing workplan/task UUIDs through one scoped child process."""
|
||||
cid = str(uuid.uuid4())
|
||||
repo = path.expanduser().resolve()
|
||||
identity = scan_record_identities(repo)
|
||||
before = _missing_identifiers(repo)
|
||||
evidence: dict[str, Any] = {
|
||||
"repo_path": str(repo),
|
||||
"repo_slug": repo.name,
|
||||
"api_base": api_base.rstrip("/"),
|
||||
"missing_before": before,
|
||||
"record_identity": identity,
|
||||
}
|
||||
|
||||
if identity["identity_collisions"]:
|
||||
return RegistrarResult(
|
||||
"rejected",
|
||||
evidence,
|
||||
{
|
||||
"code": "record_identity_collision",
|
||||
"message": "same canonical work-record id has conflicting or incomplete UUID assignments",
|
||||
},
|
||||
cid,
|
||||
)
|
||||
|
||||
repair_projection_id = None
|
||||
if repair_workplan and bootstrap_empty_projection:
|
||||
return RegistrarResult(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
|
|||
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||
from repo_manager.parse.register import iter_register_files, parse_register_file
|
||||
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
|
||||
from repo_manager.record_identity import scan_record_identities
|
||||
|
||||
|
||||
class RecordIdentityCollisionError(ValueError):
|
||||
"""Raised when one canonical record id points at conflicting UUIDs."""
|
||||
|
||||
|
||||
def _slug_from_path(repo_root: Path) -> str:
|
||||
|
|
@ -72,7 +77,7 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
|||
},
|
||||
)
|
||||
)
|
||||
for task in wp.tasks:
|
||||
for task_number, task in enumerate(wp.tasks, start=1):
|
||||
records.append(
|
||||
WorkRecordEntry(
|
||||
kind="task",
|
||||
|
|
@ -83,15 +88,23 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
|||
uuid=task.state_hub_task_id,
|
||||
parent_id=wp.id,
|
||||
extra={
|
||||
key: task.raw[key]
|
||||
for key in ("depends_on", "needs_human", "intervention_note", "blocking_reason")
|
||||
if key in task.raw
|
||||
"source_occurrence": f"{wp.path}#task-block-{task_number}",
|
||||
**{
|
||||
key: task.raw[key]
|
||||
for key in (
|
||||
"depends_on",
|
||||
"needs_human",
|
||||
"intervention_note",
|
||||
"blocking_reason",
|
||||
)
|
||||
if key in task.raw
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for path in iter_record_files(repo_root):
|
||||
for record in parse_record_file(path, repo_root=repo_root):
|
||||
for record_number, record in enumerate(parse_record_file(path, repo_root=repo_root), start=1):
|
||||
records.append(
|
||||
WorkRecordEntry(
|
||||
kind=record.kind,
|
||||
|
|
@ -100,7 +113,10 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
|||
title=record.title,
|
||||
source_path=record.source_path,
|
||||
uuid=record.uuid,
|
||||
extra={"record": record.raw},
|
||||
extra={
|
||||
"record": record.raw,
|
||||
"source_occurrence": f"{record.source_path}#record-block-{record_number}",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -117,6 +133,41 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
|||
)
|
||||
)
|
||||
|
||||
identity = scan_record_identities(repo_root)
|
||||
if identity["identity_collisions"]:
|
||||
raise RecordIdentityCollisionError(
|
||||
f"work-record identity collision: {identity['identity_collisions']}"
|
||||
)
|
||||
|
||||
diagnostic_events: list[dict] = []
|
||||
for duplicate in identity["duplicate_source_occurrences"]:
|
||||
matches = [
|
||||
record
|
||||
for record in records
|
||||
if record.kind == duplicate["kind"] and record.id == duplicate["id"]
|
||||
]
|
||||
if len(matches) < 2:
|
||||
continue
|
||||
keeper = matches[0]
|
||||
keeper.extra.pop("source_occurrence", None)
|
||||
keeper.extra["source_occurrences"] = duplicate["sources"]
|
||||
records = [record for record in records if record is keeper or record not in matches]
|
||||
diagnostic_events.append(
|
||||
{
|
||||
"type": "repo.work_record.duplicate_source_occurrence",
|
||||
"severity": "warning",
|
||||
"record_kind": duplicate["kind"],
|
||||
"record_id": duplicate["id"],
|
||||
"uuid": duplicate["uuid"],
|
||||
"source_occurrences": duplicate["sources"],
|
||||
"cleanup_required": True,
|
||||
"canon": identity["canon"],
|
||||
}
|
||||
)
|
||||
|
||||
for record in records:
|
||||
record.extra.pop("source_occurrence", None)
|
||||
|
||||
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
|
||||
fingerprint, source_files = source_fingerprint(repo_root)
|
||||
index = RepoIndex(
|
||||
|
|
@ -127,6 +178,7 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
|||
source_fingerprint=fingerprint,
|
||||
source_files=source_files,
|
||||
work_records=records,
|
||||
events=diagnostic_events,
|
||||
)
|
||||
|
||||
snapshot = {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ def iter_record_files(repo_root: Path) -> list[Path]:
|
|||
root = repo_root / relative
|
||||
if root.is_dir():
|
||||
files.update(path for path in root.rglob("*.md") if not path.name.startswith("."))
|
||||
for name in ("INTAKES.md", "DECISIONS.md"):
|
||||
# Fleet repositories use both title-case conventions. Keep these explicit:
|
||||
# arbitrary top-level Markdown remains outside the governed record surface.
|
||||
for name in ("INTAKES.md", "DECISIONS.md", "intakes.md", "decisions.md"):
|
||||
path = repo_root / name
|
||||
if path.is_file():
|
||||
files.add(path)
|
||||
|
|
|
|||
91
src/repo_manager/record_identity.py
Normal file
91
src/repo_manager/record_identity.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Work-record identity validation and duplicate-source reconciliation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
|
||||
|
||||
DEFAULT_KIND_REGISTRY = Path(__file__).resolve().parents[2] / "config" / "work-record-types.yaml"
|
||||
CANON_WORK_RECORD_TYPES = "the-custodian/canon/standards/work-record-types.yaml"
|
||||
|
||||
|
||||
def load_kind_registry(path: Path | None = None) -> dict[str, Any]:
|
||||
data = yaml.safe_load((path or DEFAULT_KIND_REGISTRY).read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("kinds"), list):
|
||||
raise TypeError("invalid work-record kind registry")
|
||||
return data
|
||||
|
||||
|
||||
def classify_record_id(
|
||||
kind: str, identifier: str, *, registry_path: Path | None = None
|
||||
) -> str | None:
|
||||
"""Return canonical/grandfathered when the canon registry accepts an id."""
|
||||
registry = load_kind_registry(registry_path)
|
||||
entry = next((item for item in registry["kinds"] if item.get("kind") == kind), None)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
if any(re.fullmatch(pattern, identifier) for pattern in entry.get("id_patterns") or []):
|
||||
return "canonical"
|
||||
for legacy in entry.get("legacy_patterns") or []:
|
||||
if legacy.get("grandfathered") and re.fullmatch(
|
||||
str(legacy.get("pattern") or ""), identifier
|
||||
):
|
||||
return "grandfathered"
|
||||
return None
|
||||
|
||||
|
||||
def scan_record_identities(repo_root: Path, *, registry_path: Path | None = None) -> dict[str, Any]:
|
||||
"""Describe invalid ids, benign duplicate sources, and hard collisions."""
|
||||
occurrences: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||
invalid: list[dict[str, str]] = []
|
||||
|
||||
def add(kind: str, identifier: str | None, record_uuid: str | None, source: str) -> None:
|
||||
if not identifier:
|
||||
return
|
||||
classification = classify_record_id(kind, identifier, registry_path=registry_path)
|
||||
item = {"kind": kind, "id": identifier, "uuid": record_uuid, "source": source}
|
||||
occurrences[(kind, identifier)].append(item)
|
||||
if classification is None:
|
||||
invalid.append({"kind": kind, "id": identifier, "source": source})
|
||||
|
||||
for path in iter_workplan_files(repo_root):
|
||||
workplan = parse_workplan_file(path, repo_root=repo_root)
|
||||
add("workplan", workplan.id, workplan.state_hub_workstream_id, workplan.path)
|
||||
for number, task in enumerate(workplan.tasks, start=1):
|
||||
add("task", task.id, task.state_hub_task_id, f"{workplan.path}#task-block-{number}")
|
||||
for path in iter_record_files(repo_root):
|
||||
for number, record in enumerate(parse_record_file(path, repo_root=repo_root), start=1):
|
||||
add(record.kind, record.id, record.uuid, f"{record.source_path}#record-block-{number}")
|
||||
|
||||
duplicates: list[dict[str, Any]] = []
|
||||
collisions: list[dict[str, Any]] = []
|
||||
for (kind, identifier), items in sorted(occurrences.items()):
|
||||
if len(items) < 2:
|
||||
continue
|
||||
uuids = {item["uuid"] for item in items}
|
||||
result = {
|
||||
"kind": kind,
|
||||
"id": identifier,
|
||||
"uuid": items[0]["uuid"] if len(uuids) == 1 else None,
|
||||
"sources": [item["source"] for item in items],
|
||||
}
|
||||
if len(uuids) == 1 and None not in uuids:
|
||||
duplicates.append(result)
|
||||
else:
|
||||
result["uuids"] = sorted(str(value) for value in uuids)
|
||||
collisions.append(result)
|
||||
|
||||
return {
|
||||
"ok": not invalid and not collisions,
|
||||
"invalid_identifiers": invalid,
|
||||
"duplicate_source_occurrences": duplicates,
|
||||
"identity_collisions": collisions,
|
||||
"canon": CANON_WORK_RECORD_TYPES,
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
"""Deprecated Repo Manager CLI adapter for the SBOM Nexus product owner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
|
@ -8,9 +9,55 @@ import subprocess
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SNAPSHOT_SCHEMA = "sbom-nexus.snapshot.v1"
|
||||
SNAPSHOT_REQUIRED_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"ok",
|
||||
"repo_slug",
|
||||
"source_revision",
|
||||
"generated_at",
|
||||
"entry_count",
|
||||
"entries",
|
||||
"sources",
|
||||
"licence_report",
|
||||
"errors",
|
||||
}
|
||||
)
|
||||
PREVIEW_CONTEXT = {
|
||||
"mode": "local-preview",
|
||||
"authoritative": False,
|
||||
"persisted": False,
|
||||
"advances_last_attempt_at": False,
|
||||
"advances_last_success_at": False,
|
||||
"creates_snapshot_history": False,
|
||||
}
|
||||
|
||||
|
||||
class SBOMContractError(ValueError):
|
||||
"""Raised when a Nexus response is outside the pinned consumer contract."""
|
||||
|
||||
|
||||
def validate_snapshot_contract(payload: dict[str, Any]) -> None:
|
||||
"""Accept additive fields but reject unknown schemas or missing required fields."""
|
||||
if payload.get("schema") != SNAPSHOT_SCHEMA:
|
||||
raise SBOMContractError(
|
||||
f"unsupported SBOM Nexus schema {payload.get('schema')!r}; expected {SNAPSHOT_SCHEMA!r}"
|
||||
)
|
||||
missing = sorted(SNAPSHOT_REQUIRED_FIELDS - payload.keys())
|
||||
if missing:
|
||||
raise SBOMContractError(f"SBOM Nexus snapshot is missing required fields: {missing}")
|
||||
|
||||
|
||||
def _mark_preview(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
payload["repo_manager_context"] = dict(PREVIEW_CONTEXT)
|
||||
payload["delegated_by"] = "repo-manager"
|
||||
payload["product_owner"] = "sbom-nexus"
|
||||
return payload
|
||||
|
||||
|
||||
def scan_repository_via_nexus(repo_root: Path, *, slug: str | None = None) -> dict[str, Any]:
|
||||
"""Delegate a local source scan to SBOM Nexus without owning scanner logic."""
|
||||
"""Delegate a non-authoritative local preview to the Nexus-owned CLI."""
|
||||
executable = os.getenv("SBOM_NEXUS_CLI") or shutil.which("sbom-nexus")
|
||||
if not executable:
|
||||
return _error(
|
||||
|
|
@ -41,8 +88,12 @@ def scan_repository_via_nexus(repo_root: Path, *, slug: str | None = None) -> di
|
|||
if not isinstance(payload, dict):
|
||||
return _error("SBOM Nexus CLI returned a non-object JSON response")
|
||||
|
||||
payload["delegated_by"] = "repo-manager"
|
||||
payload["product_owner"] = "sbom-nexus"
|
||||
try:
|
||||
validate_snapshot_contract(payload)
|
||||
except SBOMContractError as exc:
|
||||
return _error(str(exc), reason="sbom-nexus-contract")
|
||||
|
||||
_mark_preview(payload)
|
||||
if completed.returncode and payload.get("ok", True):
|
||||
payload["ok"] = False
|
||||
payload.setdefault("errors", []).append(
|
||||
|
|
@ -62,25 +113,27 @@ def licence_report_from_snapshot(snapshot: dict[str, Any]) -> dict[str, Any]:
|
|||
"source_revision": snapshot.get("source_revision"),
|
||||
"generated_at": snapshot.get("generated_at"),
|
||||
"entry_count": int(snapshot.get("entry_count") or 0),
|
||||
"licence_report": snapshot.get("licence_report") or {
|
||||
"licence_report": snapshot.get("licence_report")
|
||||
or {
|
||||
"groups": [],
|
||||
"copyleft_direct_prod": [],
|
||||
"copyleft_direct_count": 0,
|
||||
},
|
||||
"errors": snapshot.get("errors") or [],
|
||||
"repo_manager_context": dict(PREVIEW_CONTEXT),
|
||||
"delegated_by": "repo-manager",
|
||||
"product_owner": "sbom-nexus",
|
||||
}
|
||||
|
||||
|
||||
def _error(detail: str) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "sbom-nexus.snapshot.v1",
|
||||
"ok": False,
|
||||
"entry_count": 0,
|
||||
"entries": [],
|
||||
"sources": [],
|
||||
"errors": [{"reason": "sbom-nexus-delegation", "detail": detail}],
|
||||
"delegated_by": "repo-manager",
|
||||
"product_owner": "sbom-nexus",
|
||||
}
|
||||
def _error(detail: str, *, reason: str = "sbom-nexus-delegation") -> dict[str, Any]:
|
||||
return _mark_preview(
|
||||
{
|
||||
"schema": SNAPSHOT_SCHEMA,
|
||||
"ok": False,
|
||||
"entry_count": 0,
|
||||
"entries": [],
|
||||
"sources": [],
|
||||
"errors": [{"reason": reason, "detail": detail}],
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from typing import Any, Literal
|
|||
|
||||
from repo_manager.observe import load_classification
|
||||
from repo_manager.parse.workplan import parse_frontmatter
|
||||
from repo_manager.record_identity import scan_record_identities
|
||||
|
||||
CANON_FLAVOR = "the-custodian/canon/standards/project-repository-flavor_v0.1.md"
|
||||
CANON_CLASSIFICATION = "the-custodian/canon/standards/repo-classification-standard_v1.0.md"
|
||||
|
|
@ -274,4 +275,41 @@ def check_repository(repo_root: Path, *, slug: str | None = None) -> Conformance
|
|||
canon="ADR-007",
|
||||
)
|
||||
)
|
||||
identity = scan_record_identities(repo_root)
|
||||
for item in identity["invalid_identifiers"]:
|
||||
report.findings.append(
|
||||
Finding(
|
||||
code="work-record-id-invalid",
|
||||
severity="contradictory",
|
||||
path=item["source"],
|
||||
message=f"{item['kind']} id {item['id']!r} is not accepted by the canon kind registry",
|
||||
canon=identity["canon"],
|
||||
)
|
||||
)
|
||||
for item in identity["duplicate_source_occurrences"]:
|
||||
report.findings.append(
|
||||
Finding(
|
||||
code="work-record-duplicate-source-occurrence",
|
||||
severity="warning",
|
||||
path=item["sources"][0],
|
||||
message=(
|
||||
f"{item['kind']} {item['id']!r} repeats with UUID {item['uuid']}; "
|
||||
f"index once and clean up sources {item['sources']}"
|
||||
),
|
||||
canon=identity["canon"],
|
||||
)
|
||||
)
|
||||
for item in identity["identity_collisions"]:
|
||||
report.findings.append(
|
||||
Finding(
|
||||
code="work-record-identity-collision",
|
||||
severity="contradictory",
|
||||
path=item["sources"][0],
|
||||
message=(
|
||||
f"{item['kind']} {item['id']!r} has conflicting UUID assignments "
|
||||
f"at {item['sources']}"
|
||||
),
|
||||
canon=identity["canon"],
|
||||
)
|
||||
)
|
||||
return report
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue