diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index b873d3b..b5cb43d 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -18,7 +18,7 @@ | workplan | RMGR-WP-0008 | finished | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | workplan | RMGR-WP-0009 | finished | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | | workplan | RMGR-WP-0010 | finished | — | workplans/RMGR-WP-0010-authoritative-workload-references.md | -| workplan | RMGR-WP-0011 | ready | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md | +| workplan | RMGR-WP-0011 | active | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md | | task | RMGR-WP-0001-T01 | done | — | workplans/RMGR-WP-0001-foundation.md | | task | RMGR-WP-0001-T02 | done | — | workplans/RMGR-WP-0001-foundation.md | | task | RMGR-WP-0001-T03 | done | — | workplans/RMGR-WP-0001-foundation.md | @@ -88,9 +88,9 @@ | task | RMGR-WP-0010-T05 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md | | task | RMGR-WP-0010-T06 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md | | task | RMGR-WP-0010-T07 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md | -| task | RMGR-WP-0011-T01 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md | +| task | RMGR-WP-0011-T01 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md | | task | RMGR-WP-0011-T02 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md | -| task | RMGR-WP-0011-T03 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md | +| task | RMGR-WP-0011-T03 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md | | task | RMGR-WP-0011-T04 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md | | intake | RMGR-IN-0001 | open | — | intakes/intakes.md | | intake | RMGR-IN-0002 | open | — | intakes/intakes.md | diff --git a/config/work-record-types.yaml b/config/work-record-types.yaml new file mode 100644 index 0000000..50d5ffc --- /dev/null +++ b/config/work-record-types.yaml @@ -0,0 +1,44 @@ +# Implementation snapshot of the Custodian work-record kind registry. +# Canonical source: +# the-custodian/canon/standards/work-record-types.yaml (version 0.1) +version: "0.1" +status: active +kinds: + - kind: workplan + id_patterns: + - "^[A-Z]+-WP-[0-9]{4}$" + legacy_patterns: + - pattern: "^MASON-0001$" + source: ops-mason bootstrap workplan created before WP infix convention + grandfathered: true + - kind: task + id_patterns: + - "^[A-Z]+-WP-[0-9]{4}-T[0-9]{2,3}$" + - "^ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}-T[0-9]{2}$" + legacy_patterns: + - pattern: "^[A-Z]+-WP-[0-9]{4}-T[0-9]$" + source: early single-digit task suffixes (pre task-block convention) + grandfathered: true + - pattern: "^[A-Z]+-WP-[0-9]{4}-LEGACY-T[0-9]{2}$" + source: archived-workplan legacy task renumbering + grandfathered: true + - pattern: "^MASON-0001-T[0-9]{2,3}$" + source: ops-mason bootstrap tasks created before WP infix convention + grandfathered: true + - kind: intake + id_patterns: + - "^[A-Z]+-IN-[0-9]{4}$" + legacy_patterns: + - pattern: "^AWQ-[0-9]{3}$" + source: binky-control AutopilotWorkQueue + grandfathered: true + - kind: decision + id_patterns: + - "^[A-Z]+-DEC-[0-9]{4}-[0-9]{3}$" + legacy_patterns: + - pattern: "^DEC-[0-9]{4}-[0-9]{3}$" + source: binky-control DecisionQueue + grandfathered: true +identity_reconciliation: + repeated_same_id_same_uuid: duplicate_source_occurrence + repeated_same_id_different_uuid: identity_collision diff --git a/docs/repository-registers_v0.1.md b/docs/repository-registers_v0.1.md index 7c0e7f8..38c5dab 100644 --- a/docs/repository-registers_v0.1.md +++ b/docs/repository-registers_v0.1.md @@ -74,6 +74,10 @@ SBOM Nexus is the sole scanner and snapshot product owner. Repo Manager retains these aliases only so existing repository workflows do not break; set `SBOM_NEXUS_CLI` when `sbom-nexus` is not on `PATH`. Their JSON includes `product_owner: sbom-nexus` and the Nexus `sbom-nexus.snapshot.v1` schema. +They also carry `repo_manager_context.mode: local-preview` with explicit false +values for authority, persistence, attempt/success advancement, and snapshot +history creation. See `docs/sbom-nexus-client-contract_v1.md` for the pinned +preview and production-service boundary. Detection covers `uv.lock`, `requirements.txt`, `package-lock.json`, `yarn.lock`, `Cargo.lock`, `go.sum`, `.terraform.lock.hcl`, Ansible requirements diff --git a/docs/sbom-nexus-client-contract_v1.md b/docs/sbom-nexus-client-contract_v1.md new file mode 100644 index 0000000..c48ee8c --- /dev/null +++ b/docs/sbom-nexus-client-contract_v1.md @@ -0,0 +1,72 @@ +# SBOM Nexus client contract v1 + +Repo Manager consumes SBOM Nexus; it does not implement a second scanner, +snapshot store, freshness model, licence classifier, or catch-up scheduler. +This contract pins the boundary used by `RMGR-WP-0011`. + +## Authority modes + +| Mode | Surface | Authoritative | Persisted | Advances Nexus state | +| --- | --- | --- | --- | --- | +| Local preview | `rmgr sbom scan|licence-report` → `sbom-nexus scan` | no | no | no | +| Service operation | configured SBOM Nexus HTTP client | yes | Nexus decides | yes, according to the route outcome | + +Every compatibility-command result carries: + +```json +{ + "repo_manager_context": { + "mode": "local-preview", + "authoritative": false, + "persisted": false, + "advances_last_attempt_at": false, + "advances_last_success_at": false, + "creates_snapshot_history": false + } +} +``` + +Writing preview JSON to a file creates optional local evidence only. It does +not turn the result into an authoritative snapshot or ingest receipt. + +## Pinned snapshot envelope + +Repo Manager accepts `schema: sbom-nexus.snapshot.v1`. Additive fields are +compatible. An unknown schema or omission of any of these fields is a contract +error: `schema`, `ok`, `repo_slug`, `source_revision`, `generated_at`, +`entry_count`, `entries`, `sources`, `licence_report`, and `errors`. + +Repo Manager may project the existing licence-report compatibility shape from +that envelope. It must not recalculate licence groups or copyleft signals. + +## Authoritative service routes + +The production client may consume these Nexus-owned routes without depending +on Nexus tables or migrations: + +| Purpose | Route | +| --- | --- | +| Upsert repository projection | `PUT /repositories/{repo_slug}` | +| Read repository projections | `GET /repositories/` | +| Persist scan outcome | `POST /sbom/{repo_slug}/ingest` | +| Persist terminal skip outcome | `POST /sbom/{repo_slug}/skip` | +| Read latest repository SBOM | `GET /sbom/{repo_slug}` | +| Read immutable snapshot detail | `GET /sbom/snapshots/{snapshot_id}` | +| Read licence report | `GET /sbom/report/licences/` | + +Calls use a bounded timeout and surface deterministic transport, HTTP, and +contract errors. Credentials enter only through the platform runtime path and +must never appear in files, command output, or logs. + +## Repository source identity and provenance + +Repo Manager remains authoritative for repository slug, active state, +checkout/source identity, and Git revision. An authoritative scan request must +refer to a controlled source artifact or checkout pinned to that revision. It +must not infer identity from a path string or repository name, and it must not +mount a workstation filesystem into the cluster. + +`CUST-WP-0064` owns the coordinated source-input topology. Until that topology +is live, local checkout scans remain previews even when their Git revision is +known. SBOM Nexus alone records the resulting authoritative snapshot and +attempt/success history. diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index 3bdf6d4..1fd6aa5 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -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) diff --git a/src/repo_manager/commands/registrar_reconcile.py b/src/repo_manager/commands/registrar_reconcile.py index 479aae8..6a8f4e4 100644 --- a/src/repo_manager/commands/registrar_reconcile.py +++ b/src/repo_manager/commands/registrar_reconcile.py @@ -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( diff --git a/src/repo_manager/observe.py b/src/repo_manager/observe.py index bf1d845..8618f89 100644 --- a/src/repo_manager/observe.py +++ b/src/repo_manager/observe.py @@ -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 = { diff --git a/src/repo_manager/parse/record.py b/src/repo_manager/parse/record.py index 4394bad..611049a 100644 --- a/src/repo_manager/parse/record.py +++ b/src/repo_manager/parse/record.py @@ -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) diff --git a/src/repo_manager/record_identity.py b/src/repo_manager/record_identity.py new file mode 100644 index 0000000..c6dc9d9 --- /dev/null +++ b/src/repo_manager/record_identity.py @@ -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, + } diff --git a/src/repo_manager/sbom_client.py b/src/repo_manager/sbom_client.py index 425113d..b04f346 100644 --- a/src/repo_manager/sbom_client.py +++ b/src/repo_manager/sbom_client.py @@ -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}], + } + ) diff --git a/src/repo_manager/standards.py b/src/repo_manager/standards.py index 5dc94ea..15dc545 100644 --- a/src/repo_manager/standards.py +++ b/src/repo_manager/standards.py @@ -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 diff --git a/tests/test_record_identity.py b/tests/test_record_identity.py new file mode 100644 index 0000000..0f13a0c --- /dev/null +++ b/tests/test_record_identity.py @@ -0,0 +1,87 @@ +from pathlib import Path + +import pytest + +from repo_manager.observe import RecordIdentityCollisionError, observe_repository +from repo_manager.record_identity import classify_record_id, scan_record_identities +from repo_manager.standards import check_repository + + +def _write_mason_workplan(repo: Path, second_uuid: str) -> None: + path = repo / "workplans" / "MASON-0001-statehub-bootstrap.md" + path.parent.mkdir(parents=True) + path.write_text( + f"""--- +id: MASON-0001 +type: workplan +title: Bootstrap +status: finished +state_hub_workstream_id: "11111111-1111-4111-8111-111111111111" +--- + +## First occurrence + +```task +id: MASON-0001-T01 +status: done +priority: high +state_hub_task_id: "ede1aa62-fc42-494a-9984-3190a52481af" +``` + +## Second occurrence + +```task +id: MASON-0001-T01 +status: done +priority: high +state_hub_task_id: "{second_uuid}" +``` +""", + encoding="utf-8", + ) + + +def test_grandfathered_mason_identity_is_loaded_from_canon_registry() -> None: + assert classify_record_id("workplan", "MASON-0001") == "grandfathered" + assert classify_record_id("task", "MASON-0001-T01") == "grandfathered" + + +def test_same_id_and_uuid_indexes_once_with_cleanup_diagnostic(tmp_path: Path) -> None: + _write_mason_workplan(tmp_path, "ede1aa62-fc42-494a-9984-3190a52481af") + + report = scan_record_identities(tmp_path) + assert report["identity_collisions"] == [] + assert report["duplicate_source_occurrences"][0]["sources"] == [ + "workplans/MASON-0001-statehub-bootstrap.md#task-block-1", + "workplans/MASON-0001-statehub-bootstrap.md#task-block-2", + ] + + snapshot, index = observe_repository(tmp_path, slug="ops-mason") + tasks = [record for record in index.work_records if record.kind == "task"] + assert snapshot["index"]["task_count"] == 1 + assert len(tasks) == 1 + assert ( + tasks[0].extra["source_occurrences"] == report["duplicate_source_occurrences"][0]["sources"] + ) + assert index.events[0]["type"] == "repo.work_record.duplicate_source_occurrence" + + conformance = check_repository(tmp_path, slug="ops-mason") + codes = [finding.code for finding in conformance.findings] + assert "work-record-duplicate-source-occurrence" in codes + assert not any( + finding.code == "work-record-id-invalid" and finding.path.startswith("workplans/") + for finding in conformance.findings + ) + + +def test_same_id_with_different_uuids_fails_closed(tmp_path: Path) -> None: + _write_mason_workplan(tmp_path, "22222222-2222-4222-8222-222222222222") + + report = scan_record_identities(tmp_path) + assert report["duplicate_source_occurrences"] == [] + assert report["identity_collisions"][0]["id"] == "MASON-0001-T01" + with pytest.raises(RecordIdentityCollisionError): + observe_repository(tmp_path, slug="ops-mason") + + conformance = check_repository(tmp_path, slug="ops-mason") + assert any(finding.code == "work-record-identity-collision" for finding in conformance.findings) diff --git a/tests/test_registrar_reconcile.py b/tests/test_registrar_reconcile.py index d914b3d..1b3593f 100644 --- a/tests/test_registrar_reconcile.py +++ b/tests/test_registrar_reconcile.py @@ -120,6 +120,39 @@ status: open assert missing["decisions"] == ["DEMO-DEC-0001"] +def test_missing_scan_includes_lowercase_top_level_record_files(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + (repo / "intakes.md").write_text( + """# Intakes + +```yaml +id: DEMO-IN-0002 +kind: intake +title: Standalone intake +status: open +``` +""", + encoding="utf-8", + ) + (repo / "decisions.md").write_text( + """# Decisions + +```yaml +id: DEMO-DEC-0002 +kind: decision +title: Standalone decision +status: accepted +``` +""", + encoding="utf-8", + ) + + missing = rr._missing_identifiers(repo) + + assert missing["intakes"] == ["DEMO-IN-0002"] + assert missing["decisions"] == ["DEMO-DEC-0002"] + + def test_scopes_registrar_env_and_commits_assigned_ids(tmp_path: Path, monkeypatch) -> None: repo = _fixture(tmp_path) monkeypatch.setattr(rr, "_check_primary", lambda _api: ({"status": "ok", "db": "connected"}, None)) diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 4d5f757..75154bc 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -4,10 +4,14 @@ import json import subprocess from pathlib import Path +import pytest + from repo_manager.cli import main from repo_manager.sbom_client import ( + SBOMContractError, licence_report_from_snapshot, scan_repository_via_nexus, + validate_snapshot_contract, ) @@ -74,6 +78,14 @@ def test_scan_delegates_to_sbom_nexus_without_shell(monkeypatch, tmp_path: Path) assert result["schema"] == "sbom-nexus.snapshot.v1" assert result["product_owner"] == "sbom-nexus" assert result["delegated_by"] == "repo-manager" + assert result["repo_manager_context"] == { + "mode": "local-preview", + "authoritative": False, + "persisted": False, + "advances_last_attempt_at": False, + "advances_last_success_at": False, + "creates_snapshot_history": False, + } def test_missing_nexus_cli_returns_actionable_error(monkeypatch, tmp_path: Path) -> None: @@ -98,10 +110,41 @@ def test_licence_report_alias_preserves_shape() -> None: "entry_count", "licence_report", "errors", + "repo_manager_context", "delegated_by", "product_owner", } assert result["licence_report"]["copyleft_direct_count"] == 0 + assert result["repo_manager_context"]["authoritative"] is False + + +def test_snapshot_contract_allows_additive_fields_and_rejects_unknown_schema() -> None: + validate_snapshot_contract({**_snapshot(), "future_addition": {"accepted": True}}) + + with pytest.raises(SBOMContractError, match="unsupported SBOM Nexus schema"): + validate_snapshot_contract({**_snapshot(), "schema": "sbom-nexus.snapshot.v2"}) + + +def test_scan_turns_unknown_schema_into_deterministic_contract_error( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv("SBOM_NEXUS_CLI", "/opt/sbom-nexus/bin/sbom-nexus") + monkeypatch.setattr( + subprocess, + "run", + lambda command, **kwargs: subprocess.CompletedProcess( + command, + 0, + json.dumps({**_snapshot(), "schema": "future.snapshot.v9"}), + "", + ), + ) + + result = scan_repository_via_nexus(tmp_path) + + assert result["ok"] is False + assert result["errors"][0]["reason"] == "sbom-nexus-contract" + assert result["repo_manager_context"]["persisted"] is False def test_cli_scan_preserves_output_file_behavior(monkeypatch, tmp_path: Path, capsys) -> None: diff --git a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md index eb14802..4b9b764 100644 --- a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md +++ b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md @@ -288,6 +288,25 @@ two older workplans still carry production-absent random UUIDs and remain T04 work; T02 therefore stays `wait`. Evidence: `docs/evidence/RMGR-WP-0005-rail-kubernetes-registrar-2026-08-22.md`. +**Standalone-record and duplicate-source hardening (2026-08-22):** owner +feedback exposed that the governed top-level `intakes.md`/`decisions.md` +filenames were absent from Repo Manager's explicit record-file allowlist. +Consequently an intake-only reconciliation could return `noop`; it happened to +work only when another missing record caused State Hub's broader pass to run. +The scanner now recognizes both lowercase and uppercase governed filenames, +and a direct scan of the Custodian source returns exactly `CUST-IN-0014` as +missing. + +The registrar and conformance paths now also implement the Custodian kind +registry's identity-reconciliation ruling. Repeated canonical id plus the same +non-null UUID is one indexed record with all source occurrences retained and a +governed cleanup warning; conflicting or incomplete UUID assignments fail +closed. The grandfathered `MASON-0001` and `MASON-0001-TNN` forms are loaded +from the machine-readable canon mapping instead of being rewritten. Live proof +against `ops-mason` indexes `MASON-0001-T01` once, preserves both task-block +locations, and emits the cleanup diagnostic. T02 remains `wait` because its +original pre-derivation reconciliation inventory is not yet exhausted. + ## Derive identifiers deterministically ```task diff --git a/workplans/RMGR-WP-0011-sbom-nexus-production-client.md b/workplans/RMGR-WP-0011-sbom-nexus-production-client.md index 4177090..a96753e 100644 --- a/workplans/RMGR-WP-0011-sbom-nexus-production-client.md +++ b/workplans/RMGR-WP-0011-sbom-nexus-production-client.md @@ -4,7 +4,7 @@ type: workplan title: "SBOM Nexus production client and explicit preview semantics" domain: infotech repo: repo-manager -status: ready +status: active owner: codex topic_slug: infotech quality_dor: DoR-Ok @@ -13,6 +13,7 @@ updated: "2026-08-22" parent_workplan: SBOM-WP-0002 related: - CUST-WP-0062 + - CUST-WP-0064 - RMGR-WP-0008 state_hub_workstream_id: "a6cd9248-e591-51fc-82b0-a0f3799c3939" --- @@ -36,7 +37,7 @@ for callers that require authoritative state. ```task id: RMGR-WP-0011-T01 -status: todo +status: done priority: high state_hub_task_id: "b3b0f4d9-de14-5429-bc4d-a14d014491b0" ``` @@ -46,6 +47,15 @@ authoritative service routes. Treat additive fields as compatible, reject an unknown schema, and do not couple Repo Manager to Nexus database tables or migration internals. +**Result (2026-08-22):** `docs/sbom-nexus-client-contract_v1.md` pins the +accepted `sbom-nexus.snapshot.v1` envelope and the Nexus-owned repository, +ingest/skip, latest-snapshot, immutable-history, and licence-report routes. +Additive response fields remain compatible; unknown schemas and missing +required fields produce deterministic contract errors. The source handoff now +joins `CUST-WP-0064`: Repo Manager supplies repository identity and revision, +while the authoritative scan consumes a controlled revision-pinned source. A +workstation filesystem must never be mounted into the cluster. + ## Add the authoritative Nexus service client ```task @@ -64,7 +74,7 @@ introduced through the platform path, must never enter files, output, or logs. ```task id: RMGR-WP-0011-T03 -status: todo +status: done priority: high state_hub_task_id: "5cd717de-d696-5676-9abe-f1a701e48e7e" ``` @@ -75,6 +85,13 @@ non-authoritative, and not persisted. The compatibility aliases must not imply that a preview advanced `last_attempt_at`, `last_success_at`, or snapshot history. +**Result (2026-08-22):** both compatibility aliases now identify every success +and error as `mode: local-preview`, `authoritative: false`, `persisted: false`, +and explicitly state that no last-attempt, last-success, or snapshot-history +state advances. Saving preview JSON remains optional local evidence and cannot +be interpreted as an ingest receipt. CLI help, operator documentation, and +contract tests carry the same semantics. + ## Prove cutover and remove ambiguity ```task