diff --git a/docs/repository-standards_v0.1.md b/docs/repository-standards_v0.1.md index 5b2d854..259bbd5 100644 --- a/docs/repository-standards_v0.1.md +++ b/docs/repository-standards_v0.1.md @@ -79,6 +79,13 @@ pass the central API explicitly; under ADR-010 the workstation service at that invocation. Unrelated consistency failures remain visible but do not turn a successfully verified scoped registration into a false failure. +Identity preflight follows the same scope. An invalid identifier or conflicting +UUID assignment in the requested set fails closed before State Hub runs. Legacy +identity defects elsewhere in the repository remain in the command evidence and +the consistency report, but do not block assignment of unrelated canonical +records. Empty-projection bootstrap remains a full-repository operation and +therefore still requires the complete identity set to pass. + Work: `RMGR-WP-0005-T01`. ## Coding-assistant commit provenance diff --git a/src/repo_manager/commands/registrar_reconcile.py b/src/repo_manager/commands/registrar_reconcile.py index a4cc881..5c8dda5 100644 --- a/src/repo_manager/commands/registrar_reconcile.py +++ b/src/repo_manager/commands/registrar_reconcile.py @@ -101,6 +101,42 @@ def _missing_identifiers(repo: Path) -> dict[str, list[str]]: } +def _requested_identity_findings( + identity: dict[str, Any], + requested: dict[str, list[str]], + *, + full_repository: bool = False, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Return invalid IDs and collisions that can affect this invocation. + + Ordinary registrar reconciliation is deliberately scoped to the records + missing projection UUIDs. Historical defects elsewhere in the repository + remain visible in ``record_identity`` evidence, but cannot prevent safe + assignment of an unrelated canonical ID. A full empty-projection rebuild + still requires the entire repository identity set to be valid. + """ + if full_repository: + return ( + list(identity["invalid_identifiers"]), + list(identity["identity_collisions"]), + ) + + requested_by_kind = { + "workplan": set(requested["workplans"]), + "task": set(requested["tasks"]), + "intake": set(requested["intakes"]), + "decision": set(requested["decisions"]), + } + + def affects_request(item: dict[str, Any]) -> bool: + return item.get("id") in requested_by_kind.get(str(item.get("kind")), set()) + + return ( + [item for item in identity["invalid_identifiers"] if affects_request(item)], + [item for item in identity["identity_collisions"] if affects_request(item)], + ) + + def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]: status = _git(repo, "status", "--porcelain") if status.returncode != 0: @@ -390,13 +426,30 @@ def registrar_reconcile( "record_identity": identity, } - if identity["identity_collisions"]: + blocking_invalid, blocking_collisions = _requested_identity_findings( + identity, + before, + full_repository=bootstrap_empty_projection, + ) + evidence["blocking_invalid_identifiers"] = blocking_invalid + evidence["blocking_identity_collisions"] = blocking_collisions + if blocking_invalid: + return RegistrarResult( + "rejected", + evidence, + { + "code": "record_identifier_invalid", + "message": "a requested work-record id is not accepted by the canon registry", + }, + cid, + ) + if blocking_collisions: return RegistrarResult( "rejected", evidence, { "code": "record_identity_collision", - "message": "same canonical work-record id has conflicting or incomplete UUID assignments", + "message": "a requested canonical work-record id has conflicting or incomplete UUID assignments", }, cid, ) diff --git a/tests/test_registrar_reconcile.py b/tests/test_registrar_reconcile.py index 2f82bb5..a5fd278 100644 --- a/tests/test_registrar_reconcile.py +++ b/tests/test_registrar_reconcile.py @@ -51,6 +51,87 @@ def test_requires_explicit_primary_confirmation(tmp_path: Path) -> None: assert result.error and result.error["code"] == "confirmation_required" +def test_unrelated_historical_collision_does_not_block_scoped_request( + tmp_path: Path, +) -> None: + repo = _fixture(tmp_path) + for number, task_uuid in ( + (2, "22222222-2222-4222-8222-222222222222"), + (3, "33333333-3333-4333-8333-333333333333"), + ): + (repo / "workplans" / f"DEMO-WP-000{number}.md").write_text( + f"""--- +id: DEMO-WP-000{number} +type: workplan +title: Historical {number} +status: finished +state_hub_workstream_id: "{number}{'1' * 7}-1111-4111-8111-111111111111" +--- + +## Historical task + +```task +id: DEMO-WP-9999-T01 +status: done +priority: low +state_hub_task_id: "{task_uuid}" +``` +""", + encoding="utf-8", + ) + + result = rr.registrar_reconcile(repo) + + assert result.status == "rejected" + assert result.error and result.error["code"] == "confirmation_required" + assert result.evidence["record_identity"]["identity_collisions"] + assert result.evidence["blocking_identity_collisions"] == [] + + +def test_requested_collision_still_fails_closed(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + second = repo / "workplans" / "DEMO-WP-0002.md" + second.write_text( + """--- +id: DEMO-WP-0002 +type: workplan +title: Conflicting request +status: active +--- + +## Conflicting task + +```task +id: DEMO-WP-0001-T01 +status: todo +priority: high +``` +""", + encoding="utf-8", + ) + + result = rr.registrar_reconcile(repo) + + assert result.status == "rejected" + assert result.error and result.error["code"] == "record_identity_collision" + assert result.evidence["blocking_identity_collisions"][0]["id"] == "DEMO-WP-0001-T01" + + +def test_requested_invalid_identifier_fails_before_registration(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + workplan = repo / "workplans" / "DEMO-WP-0001.md" + workplan.write_text( + workplan.read_text(encoding="utf-8").replace("DEMO-WP-0001", "DEMO-INVALID"), + encoding="utf-8", + ) + + result = rr.registrar_reconcile(repo) + + assert result.status == "rejected" + assert result.error and result.error["code"] == "record_identifier_invalid" + assert result.evidence["blocking_invalid_identifiers"][0]["id"] == "DEMO-INVALID" + + def test_unlinked_closed_workplan_is_registrar_work(tmp_path: Path) -> None: repo = _fixture(tmp_path) workplan = repo / "workplans" / "DEMO-WP-0001.md" diff --git a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md index f5c17cd..83ac1ae 100644 --- a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md +++ b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md @@ -307,6 +307,15 @@ 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. +**Scoped identity compatibility (2026-08-23):** Custodian owner work exposed +that the on-demand registrar still applied its collision gate repository-wide: +unrelated grandfathered bare `T01`–`T09` collisions prevented assignment of the +canonical `CUST-WP-0065-T01`–`T04` set. The preflight now intersects invalid IDs +and collision findings with the exact missing records requested by the command. +A requested defect still fails closed; unrelated history stays in evidence; +empty-projection bootstrap retains the full-repository gate. Regression coverage +proves all three cases. + ## Derive identifiers deterministically ```task