fix(registrar): bind workplan files to their records in the same pass

backing_filename is what lets the read model tell a file-backed workplan from
a hub-only orphan. It is written by PUT /workplans/index/bindings, which
fix-consistency calls for workplans that already carry a UUID — but the
registrar mints the UUID afterwards, so a freshly registered workplan stayed
unbound until someone happened to run fix-consistency a second time.

Nobody did: 278 of 800 workplans on central recorded no backing file,
including four active and four ready. ADR-010 predicted this as the
"broken links" class.

The registrar now syncs bindings after minting, and on the noop path too —
otherwise a record whose earlier bind failed stays unbound forever, because
every later run returns early.

Binding never fails the registration: the identifiers are already minted and
committed, and a bind can be retried.

Status is sent only when already canonical. The binding schema validates
against the enum without normalising, so one legacy value 422s the whole
batch; omitting beats guessing a mapping that could drift from canon.

Refs CUST-WP-0068-T07

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
tegwick 2026-08-25 15:39:11 +02:00
parent 667bac3080
commit bdb981be2b
2 changed files with 167 additions and 1 deletions

View file

@ -21,7 +21,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.parse.workplan import iter_workplan_files, parse_workplan_file
from repo_manager.record_identity import scan_record_identities
LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock")
@ -176,6 +176,65 @@ def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]:
}, None
def _sync_workplan_bindings(repo: Path, api_base: str, repo_slug: str) -> dict[str, Any]:
"""Record which file backs each workplan, immediately after minting its id.
`backing_filename` is what makes a hub record re-derivable: without it the
read model cannot tell a file-backed workplan from a hub-only orphan. It is
written by `PUT /workplans/index/bindings`, which `fix-consistency` calls
for workplans that *already* carry a UUID but the registrar mints the UUID
afterwards, so a freshly registered workplan stayed unbound until someone
happened to run `fix-consistency` a second time. Nobody did, and 35% of
central's workplans recorded no backing file as a result
(CUST-WP-0068-T07).
Binding here closes the window rather than adding a step an operator has to
remember, which is how the gap opened in the first place.
"""
# The binding schema validates `status` against the canonical enum and does
# not normalise, so anything else is a 422 that would drop the whole batch.
# Status is optional metadata here; omit what we cannot vouch for rather
# than guess a mapping that could drift from canon.
canonical = {
"proposed", "ready", "active", "blocked", "backlog", "finished", "archived",
}
bindings: list[dict[str, Any]] = []
for path in iter_workplan_files(repo):
parsed = parse_workplan_file(path, repo_root=repo)
if not parsed.state_hub_workstream_id:
continue
bindings.append(
{
"workplan_id": parsed.state_hub_workstream_id,
"filename": path.name,
"relative_path": str(path.relative_to(repo).as_posix()),
"repo_slug": repo_slug,
"archived": path.parent.name == "archived",
"status": parsed.status if parsed.status in canonical else None,
}
)
if not bindings:
return {"skipped": "no workplans carry an identifier"}
try:
response = httpx.put(
f"{api_base.rstrip('/')}/workplans/index/bindings",
json={"bindings": bindings},
timeout=30.0,
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
# Never fail the registration for this: the identifiers are already
# minted and committed, and the binding can be re-synced.
return {"ok": False, "error": str(exc), "sent": len(bindings)}
updated = payload.get("updated", 0)
result = {"ok": True, "updated": updated, "sent": len(bindings)}
if updated < len(bindings):
# Expected while records exist in files but not yet on this hub.
result["unbound"] = len(bindings) - updated
return result
def _check_primary(
api_base: str, *, allow_unverified: bool = False
) -> tuple[dict[str, Any], str | None]:
@ -520,6 +579,10 @@ def registrar_reconcile(
)
if not any(before.values()) and not repair_projection_id and not bootstrap_empty_projection:
evidence["missing_after"] = before
# Bind on this path too. Nothing needs minting, but a previous run may
# have minted and failed to bind — without this, such records stay
# unbound forever because every later run lands here.
evidence["workplan_bindings"] = _sync_workplan_bindings(repo, api_base, repo.name)
return RegistrarResult("noop", evidence, None, cid)
git_evidence, git_error = _check_git(repo)
@ -737,4 +800,6 @@ def registrar_reconcile(
cid,
)
evidence["workplan_bindings"] = _sync_workplan_bindings(repo, api_base, repo.name)
return RegistrarResult("applied", evidence, None, cid)