fix(identifier): verify migration per repository unit

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-22 00:02:48 +02:00
parent 956efbb7ae
commit 887943108e
6 changed files with 49 additions and 19 deletions

View file

@ -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.

View file

@ -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"
}

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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)