fix(registrar): register records that have an identifier but are absent from the hub

Two blockers kept 268 work records unrecoverable.

1. The registrar only looked for *missing* identifiers, so a record whose
   derived identifier was already in the file but absent from central was
   invisible to it and the run short-circuited to noop. C-06 handles exactly
   that case and marks it fixable; only the early return stood in the way.
   Records the hub holds under a *different* identifier are deliberately not
   touched — that is a duplicate-registration identity decision.

2. repo-onboard now corrects a stale remote_url from the working copy's origin.
   The forge migration moved every repository from Gitea to Forgejo but never
   updated the hub, leaving 50 records pointing at a retired forge. State Hub
   matches a checkout to its record by remote_url, so it could not find those
   repositories and refused to register any of their work records — the error
   surfaced only in a child process's stderr.

Verified on kaizen-agentic: 8 records on central before, 15 after.

Refs CUST-WP-0068-T06

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 19:42:10 +02:00
parent bdb981be2b
commit ebf114abd6
2 changed files with 60 additions and 1 deletions

View file

@ -102,6 +102,43 @@ def _missing_identifiers(repo: Path) -> dict[str, list[str]]:
}
def _absent_from_projection(repo: Path, api_base: str) -> list[str]:
"""Workplans whose identifier is in the file but not on the authoritative hub.
These are registration work, but `_missing_identifiers` cannot see them: the
file *has* an identifier, so nothing is missing by that measure and the run
short-circuits to `noop`. C-06 handles exactly this case an authoritative
identifier absent from a projection and marks it fixable, so the only
thing standing between these records and registration was the early return
(CUST-WP-0068-T06).
Only reports records the hub genuinely does not hold. A record the hub holds
under a *different* identifier is a duplicate registration, which is an
identity decision and deliberately not handled here.
"""
workplans_dir = repo / "workplans"
if not workplans_dir.is_dir():
return []
absent: list[str] = []
for path in sorted(workplans_dir.glob("*.md")):
parsed = parse_workplan_file(path, repo_root=repo)
if parsed.frontmatter.get("type") != "workplan" or not parsed.id:
continue
if not parsed.state_hub_workstream_id:
continue
try:
response = httpx.get(
f"{api_base.rstrip('/')}/workplans/{parsed.state_hub_workstream_id}",
timeout=10.0,
)
except httpx.HTTPError:
# Never invent registration work from a transport failure.
return []
if response.status_code == 404:
absent.append(parsed.id)
return absent
def _requested_identity_findings(
identity: dict[str, Any],
requested: dict[str, list[str]],
@ -577,7 +614,16 @@ def registrar_reconcile(
},
cid,
)
if not any(before.values()) and not repair_projection_id and not bootstrap_empty_projection:
absent = _absent_from_projection(repo, api_base)
if absent:
evidence["absent_from_projection"] = absent
if (
not any(before.values())
and not absent
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

View file

@ -181,6 +181,19 @@ def onboard_repo(
else:
result.action = "updated"
# Correct a stale remote before anything else depends on it. The forge
# migration moved every repository from Gitea to Forgejo but never updated
# the hub, leaving 50 records pointing at a retired forge — and
# `fix-consistency` matches a checkout to its record *by remote_url*, so it
# could not find them and refused to register any of their work records.
# The working copy's origin is the derivable truth (CUST-WP-0068-T06).
if remote and existing and existing.get("remote_url") != remote:
code, _ = _hub(api_base, "PATCH", f"/repos/{slug}", {"remote_url": remote})
result.step(
"remote_url_corrected", code in (200, 201),
was=existing.get("remote_url"), now=remote, status=code,
)
code, patched = _hub(api_base, "PATCH", f"/repos/{slug}", _classification_body(data))
if code in (200, 201):
result.step("hub_classification", True, status=code)