From 887943108e031e415f411007117c8cb06005fd2d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 22 Aug 2026 00:02:48 +0200 Subject: [PATCH] fix(identifier): verify migration per repository unit Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d --- ...lixforge-migration-readiness-2026-08-21.md | 14 +++++++---- ...xforge-uuid-migration-plan-2026-08-21.json | 16 ++++++------- docs/work-record-uuid-derivation_v1.md | 9 +++++-- src/repo_manager/cli.py | 3 ++- src/repo_manager/identifiers.py | 24 +++++++++++++++---- tests/test_identifiers.py | 2 ++ 6 files changed, 49 insertions(+), 19 deletions(-) diff --git a/docs/evidence/RMGR-WP-0005-helixforge-migration-readiness-2026-08-21.md b/docs/evidence/RMGR-WP-0005-helixforge-migration-readiness-2026-08-21.md index dcdfede..5391041 100644 --- a/docs/evidence/RMGR-WP-0005-helixforge-migration-readiness-2026-08-21.md +++ b/docs/evidence/RMGR-WP-0005-helixforge-migration-readiness-2026-08-21.md @@ -23,13 +23,18 @@ namespace. Repo Manager records it in `config/fleet-namespace.yaml`; omitting - regenerated plan: 42/42 repositories eligible, 242 records, 209 replacements, 33 assignments, zero skipped; - plan SHA-256: - `98a7d876d49fbec9c062f970c18e25750da4cd4b5c89ab0d4ed24d50183abbda`. + `122ce72b27c2df50fb5604f3b324b68fbf7d2ec392c041370bfba7e0850a0e26`. The full old-to-derived mapping is `docs/evidence/RMGR-WP-0005-helixforge-uuid-migration-plan-2026-08-21.json`. It carries canonical UTC generation time plus per-repository Git HEAD and -authoritative-source fingerprints. `rmgr identifier migration-verify` validates -the seal and all 42 source preconditions. +authoritative-source fingerprints. Source drift is a hard failure; HEAD drift +with identical authoritative bytes is reported but remains eligible, avoiding +self-invalidation when the plan itself is committed. `rmgr identifier migration-verify` validates +the seal and source preconditions, either fleet-wide or for one repository +atomic unit. Concurrent changes observed after the first seal were correctly +rejected; `--repo` prevents unrelated fleet churn from invalidating a stable +unit while still refusing the changed repository. ## Central-projection gate @@ -51,4 +56,5 @@ references split. Apply remains unauthorized until State Hub has: 4. an isolated PostgreSQL rehearsal proving forward and reverse mappings. This is a discovered compatibility requirement, not a source-data blocker. The -sealed plan remains evidence; any source or HEAD drift requires regeneration. +sealed plan remains evidence; source or HEAD drift requires regeneration for +the affected repository before its apply. diff --git a/docs/evidence/RMGR-WP-0005-helixforge-uuid-migration-plan-2026-08-21.json b/docs/evidence/RMGR-WP-0005-helixforge-uuid-migration-plan-2026-08-21.json index a76ada8..c300c32 100644 --- a/docs/evidence/RMGR-WP-0005-helixforge-uuid-migration-plan-2026-08-21.json +++ b/docs/evidence/RMGR-WP-0005-helixforge-uuid-migration-plan-2026-08-21.json @@ -8,7 +8,7 @@ "namespace_uuid": "a4058507-5c4a-5a00-ab06-fffa4fb46009", "scope": "live workplans and unfinished tasks", "apply_policy": "all-or-nothing per repository", - "generated_at": "2026-08-21T21:55:58.740255Z", + "generated_at": "2026-08-21T22:02:06.141659Z", "totals": { "repositories": 42, "eligible": 42, @@ -206,8 +206,8 @@ { "repo": "audit-core", "path": "/home/worsch/audit-core", - "planned_head_sha": "d0790524e289acdd56e85a70d7a4ece7255421eb", - "source_fingerprint": "85bd1ad69aab0c5802add729393f6c9724ea17d1d763b8779eee3cf9ff4f326f", + "planned_head_sha": "5c5e98c8b550e0bb7c012fd5739e4db6c2f9734f", + "source_fingerprint": "0304242794b0ad6b2cc166d3e54de01dee6aebd56ff8a666e65dfc78bef31ae9", "eligible": true, "atomic_unit": true, "blockers": [], @@ -1976,7 +1976,7 @@ { "repo": "repo-manager", "path": "/home/worsch/repo-manager", - "planned_head_sha": "5dc2f3cdf66318e3814d1bb667cc9f8f108ab4d7", + "planned_head_sha": "956efbb7aef00b63e344a5a5005d1e10307e17bf", "source_fingerprint": "d64ebac83d52c784ca174db71cab24998070bee93f16ede572e1cc61d6698b58", "eligible": true, "atomic_unit": true, @@ -2164,8 +2164,8 @@ { "repo": "state-hub", "path": "/home/worsch/state-hub", - "planned_head_sha": "81861d816bec95212edd843341f663a75c6aa867", - "source_fingerprint": "14ba1e8ff89ad8f1e659565254909958c41068c7a240dd57029a35cb5cd1f41d", + "planned_head_sha": "ce52e9d1e21616051305224914962bd89047de59", + "source_fingerprint": "ea755727b529c0129f985e798170b8e85a059bf35071fca5f5cffac5ef787c36", "eligible": true, "atomic_unit": true, "blockers": [], @@ -2503,7 +2503,7 @@ { "repo": "whitehat-security", "path": "/home/worsch/whitehat-security", - "planned_head_sha": "beab2a04d1835b8ad9836a3db72a6743da6653db", + "planned_head_sha": "3770b41b74e46639db03b3c27da195bf343a1dc8", "source_fingerprint": "040460e8c099a904bcf6ab72985d670359e3c893e618e28ec7c2a525143bd0c5", "eligible": true, "atomic_unit": true, @@ -2660,5 +2660,5 @@ ] } ], - "plan_sha256": "98a7d876d49fbec9c062f970c18e25750da4cd4b5c89ab0d4ed24d50183abbda" + "plan_sha256": "122ce72b27c2df50fb5604f3b324b68fbf7d2ec392c041370bfba7e0850a0e26" } diff --git a/docs/work-record-uuid-derivation_v1.md b/docs/work-record-uuid-derivation_v1.md index 60967b0..8282904 100644 --- a/docs/work-record-uuid-derivation_v1.md +++ b/docs/work-record-uuid-derivation_v1.md @@ -43,6 +43,7 @@ rmgr identifier derive --record-id RMGR-WP-0005 rmgr identifier preflight --root /path/to/fleet rmgr identifier migration-plan --root /path/to/fleet --output uuid-migration.json rmgr identifier migration-verify --plan uuid-migration.json +rmgr identifier migration-verify --plan uuid-migration.json --repo repo-manager ``` `migration-plan` is non-mutating. Its versioned JSON output preserves every @@ -53,8 +54,12 @@ output files are not replaced unless `--force` is explicit. Plans carry a canonical UTC creation time, SHA-256 integrity seal, and per-repo Git HEAD/source fingerprints. `migration-verify` rejects a modified plan, a -namespace mismatch, an ineligible repository, or any source/HEAD change after -planning. A new plan is required after any such drift. +namespace mismatch, an ineligible repository, or authoritative-source drift. +HEAD movement is reported but is not a hard failure when the source fingerprint +is unchanged; otherwise committing the plan itself would invalidate its own +repository. `--repo` verifies one atomic apply unit, so unrelated fleet churn +does not invalidate a stable repository. A new plan is required for each unit +whose authoritative source drifts. Activation and applying a bulk migration remain separate governed steps. Publishing or planning this function does not retroactively rewrite existing diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index b6ba86a..f5e6f98 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -299,6 +299,7 @@ def main(argv: list[str] | None = None) -> int: help="Verify a sealed migration plan against current repository sources", ) p_id_verify.add_argument("--plan", required=True) + p_id_verify.add_argument("--repo", default=None, help="Verify one repository atomic unit") p_sbom = sub.add_parser("sbom", help="Derive SBOM snapshots and licence reports from repository files") sbom_sub = p_sbom.add_subparsers(dest="sbom_command") @@ -648,7 +649,7 @@ 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 = verify_identifier_migration_plan(plan) + result = verify_identifier_migration_plan(plan, repo_slug=args.repo) except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) return 1 diff --git a/src/repo_manager/identifiers.py b/src/repo_manager/identifiers.py index c15a8e8..c9cd720 100644 --- a/src/repo_manager/identifiers.py +++ b/src/repo_manager/identifiers.py @@ -217,7 +217,9 @@ def plan_identifier_migration(root: Path, namespace: str) -> dict[str, Any]: return report -def verify_identifier_migration_plan(plan: dict[str, Any]) -> dict[str, Any]: +def verify_identifier_migration_plan( + plan: dict[str, Any], *, repo_slug: str | None = None +) -> dict[str, Any]: """Verify a saved plan against its seal and current repository sources.""" errors: list[dict[str, str]] = [] if plan.get("schema") != "repo-manager.identifier-migration-plan.v1": @@ -243,29 +245,43 @@ def verify_identifier_migration_plan(plan: dict[str, Any]) -> dict[str, Any]: if not plan.get("ready_to_apply"): errors.append({"scope": "plan", "reason": "plan is not ready_to_apply"}) + repositories = list(plan.get("repositories") or []) + if repo_slug is not None: + repositories = [item for item in repositories if item.get("repo") == repo_slug] + if not repositories: + errors.append({"scope": repo_slug, "reason": "repository is absent from plan"}) + checked_repositories = 0 - for repository in plan.get("repositories") or []: + repository_results: list[dict[str, Any]] = [] + for repository in repositories: repo = Path(str(repository.get("path") or "")) scope = str(repository.get("repo") or repo.name or "repository") + before = len(errors) if not repository.get("eligible"): errors.append({"scope": scope, "reason": "repository is ineligible"}) + repository_results.append({"repo": scope, "ok": False}) continue if not repo.is_dir(): errors.append({"scope": scope, "reason": f"repository path is missing: {repo}"}) + repository_results.append({"repo": scope, "ok": False}) continue checked_repositories += 1 - if head_sha(repo) != repository.get("planned_head_sha"): - errors.append({"scope": scope, "reason": "Git HEAD changed after planning"}) + head_changed = head_sha(repo) != repository.get("planned_head_sha") current_fingerprint, _source_files = source_fingerprint(repo) if current_fingerprint != repository.get("source_fingerprint"): errors.append({"scope": scope, "reason": "authoritative source changed after planning"}) + repository_results.append( + {"repo": scope, "ok": len(errors) == before, "head_changed": head_changed} + ) return { "schema": "repo-manager.identifier-migration-verification.v1", "ok": not errors, "namespace": plan.get("namespace"), "plan_sha256": expected_seal, + "repo_filter": repo_slug, "checked_repositories": checked_repositories, + "repository_results": repository_results, "errors": errors, "source_preconditions_satisfied": not errors, "apply_authorized": False, diff --git a/tests/test_identifiers.py b/tests/test_identifiers.py index 8a26725..6f23657 100644 --- a/tests/test_identifiers.py +++ b/tests/test_identifiers.py @@ -122,6 +122,8 @@ def test_migration_verification_detects_tampering_and_source_drift(tmp_path: Pat ) plan = plan_identifier_migration(tmp_path, "helixforge") assert verify_identifier_migration_plan(plan)["ok"] is True + assert verify_identifier_migration_plan(plan, repo_slug="one")["ok"] is True + assert verify_identifier_migration_plan(plan, repo_slug="missing")["ok"] is False path.write_text(path.read_text(encoding="utf-8") + "\nchanged\n", encoding="utf-8") verification = verify_identifier_migration_plan(plan)