feat: advance conformance and deterministic ID migration

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-21 22:43:37 +02:00
parent 3791411591
commit ad621d6c0d
10 changed files with 513 additions and 20 deletions

View file

@ -286,6 +286,14 @@ def main(argv: list[str] | None = None) -> int:
p_id_derive.add_argument("--record-id", required=True)
p_id_preflight = identifier_sub.add_parser("preflight", help="Scan live identifier collisions")
p_id_preflight.add_argument("--root", default=".")
p_id_plan = identifier_sub.add_parser(
"migration-plan",
help="Emit a non-mutating, per-repository old-to-derived UUID plan",
)
p_id_plan.add_argument("--root", default=".")
p_id_plan.add_argument("--namespace", required=True)
p_id_plan.add_argument("--output", default=None, help="Write the provenance mapping as JSON")
p_id_plan.add_argument("--force", action="store_true", help="Replace an existing --output file")
args = parser.parse_args(argv)
@ -569,7 +577,11 @@ def main(argv: list[str] | None = None) -> int:
if not args.identifier_command:
p_identifier.print_help()
return 2
from repo_manager.identifiers import derive_work_record_uuid, scan_live_identifier_collisions
from repo_manager.identifiers import (
derive_work_record_uuid,
plan_identifier_migration,
scan_live_identifier_collisions,
)
if args.identifier_command == "derive":
try:
@ -583,8 +595,26 @@ def main(argv: list[str] | None = None) -> int:
"record_id": args.record_id,
"uuid": str(derived),
}
else:
elif args.identifier_command == "preflight":
result = scan_live_identifier_collisions(Path(args.root))
else:
try:
result = plan_identifier_migration(Path(args.root), args.namespace)
except ValueError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
return 1
if args.output:
output = Path(args.output)
if output.exists() and not args.force:
print(
json.dumps(
{"ok": False, "error": f"output exists: {output}; use --force to replace"},
indent=2,
)
)
return 1
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result, indent=2))
return 0 if result.get("ok") else 1

View file

@ -33,6 +33,33 @@ def derive_work_record_uuid(namespace: str, identifier: str) -> uuid.UUID:
return uuid.uuid5(DERIVATION_NAMESPACE_UUID, derivation_name(namespace, identifier))
def _migration_mapping(
*,
namespace: str,
repo: Path,
path: str,
kind: str,
identifier: str,
current_uuid: str | None,
) -> dict[str, str | None]:
derived_uuid = str(derive_work_record_uuid(namespace, identifier))
if not current_uuid:
action = "assign"
elif current_uuid == derived_uuid:
action = "unchanged"
else:
action = "replace"
return {
"repo": repo.name,
"path": path,
"kind": kind,
"record_id": identifier,
"current_uuid": current_uuid,
"derived_uuid": derived_uuid,
"action": action,
}
def scan_live_identifier_collisions(root: Path) -> dict[str, Any]:
"""Report live workplan/task identifiers that cannot safely be derived."""
by_id: dict[str, list[dict[str, str]]] = defaultdict(list)
@ -66,3 +93,99 @@ def scan_live_identifier_collisions(root: Path) -> dict[str, Any]:
"safe_to_derive": not collisions,
}
def plan_identifier_migration(root: Path, namespace: str) -> dict[str, Any]:
"""Plan a live-record UUID migration without changing files or databases.
The report is the durable old-to-derived mapping required by RMGR-WP-0005.
Eligibility is deliberately computed per repository: a repository with one
colliding or malformed live identifier is skipped as a whole.
"""
# Validate even for an empty fleet.
derivation_name(namespace, "RMGR-WP-0000")
root = root.resolve()
collision_report = scan_live_identifier_collisions(root)
collision_ids = set(collision_report["collisions"])
repositories: list[dict[str, Any]] = []
totals = {"repositories": 0, "eligible": 0, "skipped": 0, "records": 0, "replace": 0, "assign": 0, "unchanged": 0}
for repo in iter_repo_roots(root):
mappings: list[dict[str, str | None]] = []
blockers: list[dict[str, str]] = []
for path in iter_workplan_files(repo):
parsed = parse_workplan_file(path, repo_root=repo)
if parsed.status not in LIVE_WORKPLAN_STATUSES:
continue
if not parsed.id:
blockers.append({"path": parsed.path, "reason": "live workplan has no canonical id"})
continue
if parsed.id in collision_ids:
blockers.append({"path": parsed.path, "record_id": parsed.id, "reason": "live identifier collision"})
try:
mappings.append(
_migration_mapping(
namespace=namespace,
repo=repo,
path=parsed.path,
kind="workplan",
identifier=parsed.id,
current_uuid=parsed.state_hub_workstream_id,
)
)
except ValueError as exc:
blockers.append({"path": parsed.path, "record_id": parsed.id, "reason": str(exc)})
for task in parsed.tasks:
if task.status in {"done", "cancel"}:
continue
if not task.id:
blockers.append({"path": parsed.path, "reason": "unfinished task has no canonical id"})
continue
if task.id in collision_ids:
blockers.append({"path": parsed.path, "record_id": task.id, "reason": "live identifier collision"})
try:
mappings.append(
_migration_mapping(
namespace=namespace,
repo=repo,
path=parsed.path,
kind="task",
identifier=task.id,
current_uuid=task.state_hub_task_id,
)
)
except ValueError as exc:
blockers.append({"path": parsed.path, "record_id": task.id, "reason": str(exc)})
if not mappings and not blockers:
continue
eligible = not blockers
repositories.append(
{
"repo": repo.name,
"path": str(repo),
"eligible": eligible,
"atomic_unit": True,
"blockers": blockers,
"mappings": mappings,
}
)
totals["repositories"] += 1
totals["eligible" if eligible else "skipped"] += 1
totals["records"] += len(mappings)
for mapping in mappings:
totals[str(mapping["action"])] += 1
return {
"schema": "repo-manager.identifier-migration-plan.v1",
"ok": totals["skipped"] == 0,
"ready_to_apply": totals["skipped"] == 0,
"root": str(root),
"namespace": namespace.strip(),
"derivation_version": DERIVATION_VERSION,
"namespace_uuid": str(DERIVATION_NAMESPACE_UUID),
"scope": "live workplans and unfinished tasks",
"apply_policy": "all-or-nothing per repository",
"totals": totals,
"repositories": repositories,
}