feat(identifiers): verify batch projections

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-22 23:50:34 +02:00
parent e6cc18bf18
commit 055c6971ab
11 changed files with 2964 additions and 9 deletions

View file

@ -318,6 +318,13 @@ def main(argv: list[str] | None = None) -> int:
)
p_id_batch.add_argument("--plan", required=True)
p_id_batch.add_argument("--repo", action="append", required=True, dest="repos")
p_id_batch.add_argument(
"--projection-api-base",
action="append",
default=[],
dest="projection_api_bases",
help="Require current UUID=200 and derived UUID=404 on this projection",
)
p_id_batch.add_argument("--output", default=None)
p_id_batch.add_argument("--force", action="store_true")
p_id_batch_verify = identifier_sub.add_parser(
@ -772,7 +779,11 @@ def main(argv: list[str] | None = None) -> int:
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 = plan_identifier_migration_batch(plan, repo_slugs=args.repos)
result = plan_identifier_migration_batch(
plan,
repo_slugs=args.repos,
projection_api_bases=args.projection_api_bases,
)
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
return 1

View file

@ -551,7 +551,7 @@ def registrar_reconcile(
bootstrap_verified = projection_error is None
requested_verified = False
if completed.returncode == 1 and not any(after.values()):
if completed.returncode == 1 and any(before.values()) and not any(after.values()):
(
requested_workplans,
requested_tasks,
@ -578,7 +578,14 @@ def registrar_reconcile(
# A repository-scoped projection repair may coexist with legacy stale
# references that correctly keep the broader consistency report red.
accepted_exit_codes.add(1)
if completed.returncode not in accepted_exit_codes or any(after.values()):
mode_verification_failed = bool(repair_projection_id and not repair_verified) or (
bootstrap_empty_projection and not bootstrap_verified
)
if (
completed.returncode not in accepted_exit_codes
or any(after.values())
or mode_verification_failed
):
return RegistrarResult(
"failed",
evidence,

View file

@ -13,6 +13,7 @@ from collections import defaultdict
from pathlib import Path
from typing import Any
import httpx
import yaml
from repo_manager.cache import source_fingerprint
@ -359,8 +360,79 @@ 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."""
projections: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
for raw_base in api_bases:
api_base = raw_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):
errors.append(
{"scope": raw_base, "reason": "projection API base must be a plain HTTP(S) origin"}
)
continue
checks: list[dict[str, Any]] = []
try:
with httpx.Client(timeout=10.0, follow_redirects=False) as client:
for mapping in mappings:
if mapping.get("action") != "replace":
continue
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
check = {
"record_id": mapping.get("record_id"),
"kind": mapping.get("kind"),
"current_status": current.status_code,
"derived_status": derived.status_code,
"ok": check_ok,
}
checks.append(check)
if not check_ok:
errors.append(
{
"scope": f"{api_base}:{mapping.get('record_id')}",
"reason": "projection requires current UUID=200 and derived UUID=404",
}
)
except httpx.HTTPError:
errors.append({"scope": api_base, "reason": "projection API is unavailable"})
projections.append(
{
"api_base": api_base,
"ok": all(check["ok"] for check in checks),
"replacement_checks": checks,
}
)
return {
"required": True,
"ok": bool(projections) and not errors,
"projections": projections,
"errors": errors,
}
def plan_identifier_migration_batch(
plan: dict[str, Any], *, repo_slugs: list[str]
plan: dict[str, Any],
*,
repo_slugs: list[str],
projection_api_bases: list[str] | None = None,
) -> dict[str, Any]:
"""Pin a reviewed repository batch after source and Git preflight.
@ -379,6 +451,7 @@ def plan_identifier_migration_batch(
for repository in plan.get("repositories", []):
repositories_by_slug[str(repository.get("repo") or "")].append(repository)
projection_api_bases = list(projection_api_bases or [])
batch_repositories: list[dict[str, Any]] = []
totals = {"repositories": 0, "records": 0, "replace": 0, "assign": 0, "unchanged": 0}
for slug in requested:
@ -410,6 +483,12 @@ def plan_identifier_migration_batch(
errors.append({"scope": slug, "reason": error["reason"]})
for reason in git_preflight["errors"]:
errors.append({"scope": slug, "reason": reason})
projection_preflight = None
if projection_api_bases:
projection_preflight = _projection_migration_preflight(
mappings, projection_api_bases
)
errors.extend(projection_preflight["errors"])
batch_repositories.append(
{
@ -419,8 +498,14 @@ def plan_identifier_migration_batch(
"source_fingerprint": repository.get("source_fingerprint"),
"source_verified": source_verification["ok"],
"git_preflight": git_preflight,
"projection_preflight": projection_preflight,
"mapping_counts": {"records": len(mappings), **action_counts},
"ready": source_verification["ok"] and git_preflight["ok"] and actionable > 0,
"ready": (
source_verification["ok"]
and git_preflight["ok"]
and actionable > 0
and (projection_preflight is None or projection_preflight["ok"])
),
}
)
totals["repositories"] += 1
@ -434,6 +519,7 @@ def plan_identifier_migration_batch(
"ready_for_approval": not errors,
"apply_authorized": False,
"approval_required": True,
"projection_api_bases": [base.strip().rstrip("/") for base in projection_api_bases],
"namespace": plan.get("namespace"),
"source_plan_sha256": plan_sha256,
"batch_policy": "repository-atomic, sequential, stop on first failure",
@ -484,7 +570,11 @@ def verify_identifier_migration_batch(
fresh = None
else:
try:
fresh = plan_identifier_migration_batch(plan, repo_slugs=repo_slugs)
fresh = plan_identifier_migration_batch(
plan,
repo_slugs=repo_slugs,
projection_api_bases=list(batch.get("projection_api_bases") or []),
)
except (OSError, ValueError) as exc:
errors.append({"scope": "batch", "reason": str(exc)})
fresh = None