feat: support mixed identifier convergence

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
tegwick 2026-08-31 01:27:21 +02:00
parent 4901b6d623
commit 5789e8c520
5 changed files with 313 additions and 11 deletions

View file

@ -420,7 +420,10 @@ def main(argv: list[str] | None = None) -> int:
action="append",
default=[],
dest="projection_api_bases",
help="Require current UUID=200 and derived UUID=404 on this projection",
help=(
"Classify legacy/derived UUID presence on this projection; both-present "
"and neither-present mappings are refused"
),
)
p_id_batch.add_argument("--output", default=None)
p_id_batch.add_argument("--force", action="store_true")
@ -445,6 +448,18 @@ def main(argv: list[str] | None = None) -> int:
action="store_true",
help="Write files; without this flag only validate and report",
)
p_id_projection = identifier_sub.add_parser(
"migration-projection",
help="Apply or reverse one sealed repository migration on the primary hub",
)
p_id_projection.add_argument("--plan", required=True)
p_id_projection.add_argument("--repo", required=True)
p_id_projection.add_argument("--confirm-plan-sha256", required=True)
p_id_projection.add_argument("--api-base", required=True)
p_id_projection.add_argument("--expected-instance-label", default="railliance01")
p_id_projection.add_argument(
"--direction", choices=["forward", "reverse"], default="forward"
)
p_sbom = sub.add_parser(
"sbom",
@ -925,6 +940,7 @@ def main(argv: list[str] | None = None) -> int:
derive_work_record_uuid,
load_fleet_namespace,
migrate_repository_identifier_files,
migrate_repository_projection,
plan_identifier_migration,
plan_identifier_migration_batch,
scan_live_identifier_collisions,
@ -972,6 +988,22 @@ def main(argv: list[str] | None = None) -> int:
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
return 1
elif args.identifier_command == "migration-projection":
try:
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
if not isinstance(plan, dict):
raise TypeError("migration plan must be a JSON object")
result = migrate_repository_projection(
plan,
repo_slug=args.repo,
confirm_plan_sha256=args.confirm_plan_sha256,
api_base=args.api_base,
direction=args.direction,
expected_instance_label=args.expected_instance_label,
)
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
return 1
elif args.identifier_command == "migration-batch-plan":
try:
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))

View file

@ -371,7 +371,13 @@ def _git_cutover_preflight(repo: Path, *, expected_head_sha: str | None) -> dict
def _projection_migration_preflight(
mappings: list[dict[str, Any]], api_bases: list[str]
) -> dict[str, Any]:
"""Require every replacement source and no replacement target per projection."""
"""Classify every replacement without assuming which side currently exists.
A projection may legitimately be mixed when forge reconciliation created
some deterministic targets before the sealed migration ran. Legacy-source
and derived-target are both convergent states; both-present and neither-
present are ambiguous and remain hard refusals.
"""
projections: list[dict[str, Any]] = []
errors: list[dict[str, str]] = [
{
@ -409,12 +415,20 @@ def _projection_migration_preflight(
route = "workplans" if mapping.get("kind") == "workplan" else "tasks"
current = client.get(f"{api_base}/{route}/{mapping.get('current_uuid')}")
derived = client.get(f"{api_base}/{route}/{mapping.get('derived_uuid')}")
check_ok = current.status_code == 200 and derived.status_code == 404
status_pair = (current.status_code, derived.status_code)
state = {
(200, 404): "legacy_source",
(404, 200): "derived_target",
(200, 200): "both_present",
(404, 404): "neither_present",
}.get(status_pair, "unexpected_status")
check_ok = state in {"legacy_source", "derived_target"}
check = {
"record_id": mapping.get("record_id"),
"kind": mapping.get("kind"),
"current_status": current.status_code,
"derived_status": derived.status_code,
"state": state,
"ok": check_ok,
}
checks.append(check)
@ -422,15 +436,43 @@ def _projection_migration_preflight(
errors.append(
{
"scope": f"{api_base}:{mapping.get('record_id')}",
"reason": "projection requires current UUID=200 and derived UUID=404",
"reason": (
"projection is ambiguous: both legacy and derived UUIDs exist"
if state == "both_present"
else "projection is incomplete: neither legacy nor derived UUID exists"
if state == "neither_present"
else "projection returned an unexpected HTTP status"
),
}
)
except httpx.HTTPError:
errors.append({"scope": api_base, "reason": "projection API is unavailable"})
state_counts = {
state: sum(check["state"] == state for check in checks)
for state in (
"legacy_source",
"derived_target",
"both_present",
"neither_present",
"unexpected_status",
)
}
convergent_states = {check["state"] for check in checks if check["ok"]}
mode = (
"legacy_migration"
if convergent_states == {"legacy_source"}
else "file_convergence"
if convergent_states == {"derived_target"}
else "mixed_convergence"
if convergent_states == {"legacy_source", "derived_target"}
else "blocked"
)
projections.append(
{
"api_base": api_base,
"ok": all(check["ok"] for check in checks),
"mode": mode,
"state_counts": state_counts,
"replacement_checks": checks,
}
)
@ -939,3 +981,103 @@ def migrate_repository_identifier_files(
"assignments": assignments,
"database_coordinated": False,
}
def migrate_repository_projection(
plan: dict[str, Any],
*,
repo_slug: str,
confirm_plan_sha256: str,
api_base: str,
direction: str = "forward",
expected_instance_label: str | None = "railliance01",
transport: httpx.BaseTransport | None = None,
) -> dict[str, Any]:
"""Apply or reverse the sealed projection half on the authoritative hub."""
plan_sha256 = _verified_plan_seal(plan)
if confirm_plan_sha256 != plan_sha256:
raise ValueError("--confirm-plan-sha256 does not match the sealed plan")
if direction not in {"forward", "reverse"}:
raise ValueError("direction must be 'forward' or 'reverse'")
verification = verify_identifier_migration_plan(plan, repo_slug=repo_slug)
if not verification["ok"]:
reasons = "; ".join(error["reason"] for error in verification["errors"])
raise ValueError(f"repository source preconditions failed: {reasons}")
repositories = [
item for item in plan.get("repositories", []) if item.get("repo") == repo_slug
]
repository = repositories[0]
git_preflight = _git_cutover_preflight(
Path(str(repository.get("path") or "")).resolve(),
expected_head_sha=repository.get("planned_head_sha"),
)
if not git_preflight["ok"]:
raise ValueError(
"repository Git preconditions failed: " + "; ".join(git_preflight["errors"])
)
api_base = api_base.strip().rstrip("/")
try:
url = httpx.URL(api_base)
if (
url.scheme not in {"http", "https"}
or not url.host
or url.username
or url.password
or url.query
or url.fragment
):
raise ValueError
except (TypeError, ValueError) as exc:
raise ValueError("projection API base must be a plain HTTP(S) origin") from exc
headers = {
"Idempotency-Key": f"rmgr-identifier-migration:{direction}:{repo_slug}:{plan_sha256}",
"X-StateHub-Source-Agent": "repo-manager",
}
try:
with httpx.Client(
base_url=api_base,
timeout=httpx.Timeout(120.0, connect=5.0),
follow_redirects=False,
transport=transport,
) as client:
health = client.get("/state/health")
health.raise_for_status()
identity = health.json()
if identity.get("instance_role") != "primary" or (
expected_instance_label is not None
and identity.get("instance_label") != expected_instance_label
):
raise ValueError("identifier migration requires the expected primary State Hub")
response = client.post(
f"/identifier-migrations/repositories/{repo_slug}/"
f"{'apply' if direction == 'forward' else 'reverse'}",
json={
"plan": plan,
"expected_plan_sha256": plan_sha256,
"primary_confirmed": True,
},
headers=headers,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
try:
detail = exc.response.json()
except ValueError:
detail = exc.response.text[:500]
raise ValueError(f"projection migration rejected: {detail}") from exc
except httpx.HTTPError as exc:
raise ValueError(f"projection migration unavailable: {exc}") from exc
receipt = response.json()
return {
"schema": "repo-manager.identifier-projection-migration.v1",
"ok": True,
"direction": direction,
"repo": repo_slug,
"plan_sha256": plan_sha256,
"api_base": api_base,
"instance": identity,
"source_verification": verification,
"git_preflight": git_preflight,
"receipt": receipt,
}