fix(registrar): scope identity preflight
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02b22-9638-76d2-bbff-b7ea1770b118
This commit is contained in:
parent
4398167580
commit
7a15f1da21
4 changed files with 152 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue