feat(identifiers): prepare verified cutover batches
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
bdf3af19e2
commit
5e14d09bdf
10 changed files with 3097 additions and 0 deletions
|
|
@ -89,3 +89,4 @@
|
||||||
| task | RMGR-WP-0010-T07 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
| task | RMGR-WP-0010-T07 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
||||||
| intake | RMGR-IN-0001 | open | — | intakes/intakes.md |
|
| intake | RMGR-IN-0001 | open | — | intakes/intakes.md |
|
||||||
| intake | RMGR-IN-0002 | open | — | intakes/intakes.md |
|
| intake | RMGR-IN-0002 | open | — | intakes/intakes.md |
|
||||||
|
| decision | RMGR-DEC-2026-001 | open | — | decisions/RMGR-DEC-2026-001-whynot-identifier-batch.md |
|
||||||
|
|
|
||||||
47
decisions/RMGR-DEC-2026-001-whynot-identifier-batch.md
Normal file
47
decisions/RMGR-DEC-2026-001-whynot-identifier-batch.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# RMGR-DEC-2026-001 — Whynot Design identifier cutover batch
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: RMGR-DEC-2026-001
|
||||||
|
kind: decision
|
||||||
|
title: Approve deterministic identifier batch 0002 for whynot-design
|
||||||
|
status: open
|
||||||
|
decision_type: pending
|
||||||
|
owner: Bernd Worsch
|
||||||
|
repo: repo-manager
|
||||||
|
workplan: RMGR-WP-0005
|
||||||
|
workplan_task: RMGR-WP-0005-T04
|
||||||
|
requested_dispositions:
|
||||||
|
- approved
|
||||||
|
- revised
|
||||||
|
- rejected
|
||||||
|
source_plan: docs/evidence/RMGR-WP-0005-helixforge-uuid-migration-plan-2026-08-22-v2.json
|
||||||
|
source_plan_sha256: ac97f6e4f0b35db63a0dc1b4a439f0a39738d13c1407f18419f706d780d4eea0
|
||||||
|
batch_manifest: docs/evidence/RMGR-WP-0005-batch-0002-whynot-design.json
|
||||||
|
batch_sha256: 4039224352c6590fdc6b41e739539f1f9c92d098b43ed6e30b3cd9a87b53da65
|
||||||
|
scope:
|
||||||
|
repositories: [whynot-design]
|
||||||
|
replacements: 1
|
||||||
|
assignments: 0
|
||||||
|
apply_authorized: false
|
||||||
|
approval_effect: >-
|
||||||
|
Approved authorizes one repository-atomic forward cutover of exactly the
|
||||||
|
pinned batch in both current projections and the authoritative repository
|
||||||
|
file, subject to repeated no-drift and restore-point checks. Revised or
|
||||||
|
rejected authorizes no mutation.
|
||||||
|
rollback: >-
|
||||||
|
Reverse the file transaction if written, then reverse workstation and
|
||||||
|
production projection transactions using their durable aliases.
|
||||||
|
evidence: docs/evidence/RMGR-WP-0005-batch-0002-readiness-2026-08-22.md
|
||||||
|
created: "2026-08-22"
|
||||||
|
updated: "2026-08-22"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision requested
|
||||||
|
|
||||||
|
Approve, revise, or reject the exact batch manifest named above. Approval is
|
||||||
|
limited to `whynot-design` and the single `WHYNOT-WP-0003` UUID replacement; it
|
||||||
|
does not authorize any later fleet batch.
|
||||||
|
|
||||||
|
The manifest remains fail-closed: any Git, source-fingerprint, projection, or
|
||||||
|
plan-seal drift before execution voids the approval and requires a new batch
|
||||||
|
hash.
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
# RMGR-WP-0005 batch 0002 readiness
|
||||||
|
|
||||||
|
**Observed at:** `2026-08-22T12:48:35Z`
|
||||||
|
|
||||||
|
**Scope:** `whynot-design` only
|
||||||
|
|
||||||
|
**Decision:** `RMGR-DEC-2026-001`
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
The next deterministic-identifier batch is fully specified and ready for an
|
||||||
|
explicit approval decision. No database or repository identifier was changed.
|
||||||
|
|
||||||
|
The earlier fleet seal correctly rejects 15 repositories whose authoritative
|
||||||
|
sources changed after it was generated. A refreshed plan now covers 41 eligible
|
||||||
|
repositories and 230 records with zero collisions:
|
||||||
|
|
||||||
|
- 180 replacements;
|
||||||
|
- 32 assignments;
|
||||||
|
- 18 already deterministic;
|
||||||
|
- 0 skipped repositories.
|
||||||
|
|
||||||
|
Source plan:
|
||||||
|
`RMGR-WP-0005-helixforge-uuid-migration-plan-2026-08-22-v2.json`, SHA-256 seal
|
||||||
|
`ac97f6e4f0b35db63a0dc1b4a439f0a39738d13c1407f18419f706d780d4eea0`.
|
||||||
|
|
||||||
|
## Batch 0002
|
||||||
|
|
||||||
|
The proposed batch deliberately stays at one repository and one replacement:
|
||||||
|
|
||||||
|
| Repository | Record | Current UUID | Derived UUID |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `whynot-design` | `WHYNOT-WP-0003` | `41fed928-f44a-48f4-9870-120310fbf071` | `d3a6ec16-ac40-5ffb-99e7-07f997e59a4a` |
|
||||||
|
|
||||||
|
Batch manifest:
|
||||||
|
`RMGR-WP-0005-batch-0002-whynot-design.json`, SHA-256 seal
|
||||||
|
`4039224352c6590fdc6b41e739539f1f9c92d098b43ed6e30b3cd9a87b53da65`.
|
||||||
|
|
||||||
|
The manifest reports `ready_for_approval: true`, `apply_authorized: false`.
|
||||||
|
`whynot-design` is clean, exactly synchronized with `origin/main`, and its
|
||||||
|
origin is the Forgejo lineage. The plan pins HEAD
|
||||||
|
`4b62cffc86496d587ac8d48a8e199624bc4a5c1f` and source fingerprint
|
||||||
|
`55ea5e6884be2c5a0a7b8e9e19326f381c2b86afecbc28adc00252ce56793a7f`.
|
||||||
|
|
||||||
|
## Projection preflight
|
||||||
|
|
||||||
|
Read-only checks against the workstation and production projections agree:
|
||||||
|
|
||||||
|
| Check | Workstation | Production |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Current workplan UUID lookup | 200 | 200 |
|
||||||
|
| Derived workplan UUID lookup | 404 | 404 |
|
||||||
|
| Tasks linked to current workplan | 9 | 9 |
|
||||||
|
| Progress events linked to current workplan | 11 | 11 |
|
||||||
|
| Decisions linked to current workplan | 1 | 1 |
|
||||||
|
|
||||||
|
This is a useful second pilot: unlike the Repo Manager pilot, the same old row
|
||||||
|
exists in both projections and has matching dependent-record counts. The
|
||||||
|
database cascade must preserve those links while the task UUIDs remain
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Approved execution interface
|
||||||
|
|
||||||
|
If `RMGR-DEC-2026-001` is approved against the exact batch hash:
|
||||||
|
|
||||||
|
1. Re-run batch/source/Git preflight and stop on any drift.
|
||||||
|
2. Retain fresh per-database restore points and hashes.
|
||||||
|
3. Apply the repository transaction to workstation and production projections.
|
||||||
|
4. Apply the sealed authoritative file transaction for `whynot-design`.
|
||||||
|
5. Commit and push only the mapped workplan file.
|
||||||
|
6. Run consistency twice and require the derived lookup to return 200, the old
|
||||||
|
lookup to return 404, and all dependent counts to remain 9/11/1.
|
||||||
|
7. On any failure after database apply, reverse written files if necessary and
|
||||||
|
then reverse both database transactions from durable aliases.
|
||||||
|
|
||||||
|
The decision does not authorize any other repository or restoration of the
|
||||||
|
disabled production sweep.
|
||||||
47
docs/evidence/RMGR-WP-0005-batch-0002-whynot-design.json
Normal file
47
docs/evidence/RMGR-WP-0005-batch-0002-whynot-design.json
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"schema": "repo-manager.identifier-migration-batch.v1",
|
||||||
|
"ok": true,
|
||||||
|
"ready_for_approval": true,
|
||||||
|
"apply_authorized": false,
|
||||||
|
"approval_required": true,
|
||||||
|
"namespace": "helixforge",
|
||||||
|
"source_plan_sha256": "ac97f6e4f0b35db63a0dc1b4a439f0a39738d13c1407f18419f706d780d4eea0",
|
||||||
|
"batch_policy": "repository-atomic, sequential, stop on first failure",
|
||||||
|
"rollback_order": "reverse files if written, then reverse central projection",
|
||||||
|
"generated_at": "2026-08-22T12:48:35.222354Z",
|
||||||
|
"totals": {
|
||||||
|
"repositories": 1,
|
||||||
|
"records": 1,
|
||||||
|
"replace": 1,
|
||||||
|
"assign": 0,
|
||||||
|
"unchanged": 0
|
||||||
|
},
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"repo": "whynot-design",
|
||||||
|
"path": "/home/worsch/whynot-design",
|
||||||
|
"planned_head_sha": "4b62cffc86496d587ac8d48a8e199624bc4a5c1f",
|
||||||
|
"source_fingerprint": "55ea5e6884be2c5a0a7b8e9e19326f381c2b86afecbc28adc00252ce56793a7f",
|
||||||
|
"source_verified": true,
|
||||||
|
"git_preflight": {
|
||||||
|
"ok": true,
|
||||||
|
"head_sha": "4b62cffc86496d587ac8d48a8e199624bc4a5c1f",
|
||||||
|
"expected_head_sha": "4b62cffc86496d587ac8d48a8e199624bc4a5c1f",
|
||||||
|
"upstream": "origin/main",
|
||||||
|
"behind": 0,
|
||||||
|
"ahead": 0,
|
||||||
|
"origin": "forgejo-remote:coulomb/whynot-design.git",
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
"mapping_counts": {
|
||||||
|
"records": 1,
|
||||||
|
"replace": 1,
|
||||||
|
"assign": 0,
|
||||||
|
"unchanged": 0
|
||||||
|
},
|
||||||
|
"ready": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": [],
|
||||||
|
"batch_sha256": "4039224352c6590fdc6b41e739539f1f9c92d098b43ed6e30b3cd9a87b53da65"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -44,6 +44,13 @@ rmgr identifier preflight --root /path/to/fleet
|
||||||
rmgr identifier migration-plan --root /path/to/fleet --output uuid-migration.json
|
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
|
||||||
rmgr identifier migration-verify --plan uuid-migration.json --repo repo-manager
|
rmgr identifier migration-verify --plan uuid-migration.json --repo repo-manager
|
||||||
|
rmgr identifier migration-batch-plan \
|
||||||
|
--plan uuid-migration.json \
|
||||||
|
--repo whynot-design \
|
||||||
|
--output batch.json
|
||||||
|
rmgr identifier migration-batch-verify \
|
||||||
|
--plan uuid-migration.json \
|
||||||
|
--batch batch.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`migration-plan` is non-mutating. Its versioned JSON output preserves every
|
`migration-plan` is non-mutating. Its versioned JSON output preserves every
|
||||||
|
|
@ -61,6 +68,13 @@ 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
|
does not invalidate a stable repository. A new plan is required for each unit
|
||||||
whose authoritative source drifts.
|
whose authoritative source drifts.
|
||||||
|
|
||||||
|
`migration-batch-plan` converts selected atomic units into a bounded approval
|
||||||
|
package. Each selected repository must still match the planned Git HEAD, have a
|
||||||
|
clean worktree, exactly match its configured upstream, and use a non-retired
|
||||||
|
origin. The batch records these facts and its own SHA-256 seal, but always emits
|
||||||
|
`apply_authorized: false`. An explicit decision must cite that batch hash before
|
||||||
|
any database or file mutation.
|
||||||
|
|
||||||
Activation and applying a bulk migration remain separate governed steps.
|
Activation and applying a bulk migration remain separate governed steps.
|
||||||
Publishing or planning this function does not retroactively rewrite existing
|
Publishing or planning this function does not retroactively rewrite existing
|
||||||
identifiers. `--namespace` remains an explicit override for a foreign/forked
|
identifiers. `--namespace` remains an explicit override for a foreign/forked
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,20 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
)
|
)
|
||||||
p_id_verify.add_argument("--plan", required=True)
|
p_id_verify.add_argument("--plan", required=True)
|
||||||
p_id_verify.add_argument("--repo", default=None, help="Verify one repository atomic unit")
|
p_id_verify.add_argument("--repo", default=None, help="Verify one repository atomic unit")
|
||||||
|
p_id_batch = identifier_sub.add_parser(
|
||||||
|
"migration-batch-plan",
|
||||||
|
help="Pin a clean synchronized repository batch for explicit approval",
|
||||||
|
)
|
||||||
|
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("--output", default=None)
|
||||||
|
p_id_batch.add_argument("--force", action="store_true")
|
||||||
|
p_id_batch_verify = identifier_sub.add_parser(
|
||||||
|
"migration-batch-verify",
|
||||||
|
help="Verify a saved batch seal and repeat source/Git preflight",
|
||||||
|
)
|
||||||
|
p_id_batch_verify.add_argument("--plan", required=True)
|
||||||
|
p_id_batch_verify.add_argument("--batch", required=True)
|
||||||
p_id_files = identifier_sub.add_parser(
|
p_id_files = identifier_sub.add_parser(
|
||||||
"migration-files",
|
"migration-files",
|
||||||
help="Validate or execute one repository's sealed UUID file rewrite",
|
help="Validate or execute one repository's sealed UUID file rewrite",
|
||||||
|
|
@ -686,7 +700,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
load_fleet_namespace,
|
load_fleet_namespace,
|
||||||
migrate_repository_identifier_files,
|
migrate_repository_identifier_files,
|
||||||
plan_identifier_migration,
|
plan_identifier_migration,
|
||||||
|
plan_identifier_migration_batch,
|
||||||
scan_live_identifier_collisions,
|
scan_live_identifier_collisions,
|
||||||
|
verify_identifier_migration_batch,
|
||||||
verify_identifier_migration_plan,
|
verify_identifier_migration_plan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -730,6 +746,37 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
||||||
return 1
|
return 1
|
||||||
|
elif args.identifier_command == "migration-batch-plan":
|
||||||
|
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 = plan_identifier_migration_batch(plan, repo_slugs=args.repos)
|
||||||
|
except (OSError, TypeError, ValueError, json.JSONDecodeError) 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")
|
||||||
|
elif args.identifier_command == "migration-batch-verify":
|
||||||
|
try:
|
||||||
|
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
||||||
|
batch = json.loads(Path(args.batch).read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(plan, dict) or not isinstance(batch, dict):
|
||||||
|
raise TypeError("migration plan and batch must be JSON objects")
|
||||||
|
result = verify_identifier_migration_batch(batch, plan=plan)
|
||||||
|
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
||||||
|
return 1
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
result = plan_identifier_migration(Path(args.root), namespace)
|
result = plan_identifier_migration(Path(args.root), namespace)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
@ -296,6 +297,224 @@ def verify_identifier_migration_plan(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _git_cutover_preflight(repo: Path, *, expected_head_sha: str | None) -> dict[str, Any]:
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
def git(*args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
current_head = head_sha(repo)
|
||||||
|
if current_head is None:
|
||||||
|
errors.append("repository has no Git HEAD")
|
||||||
|
elif current_head != expected_head_sha:
|
||||||
|
errors.append("Git HEAD changed after migration planning")
|
||||||
|
|
||||||
|
status = git("status", "--porcelain")
|
||||||
|
if status.returncode != 0:
|
||||||
|
errors.append(status.stderr.strip() or "could not inspect Git worktree")
|
||||||
|
elif status.stdout.strip():
|
||||||
|
errors.append("worktree is not clean")
|
||||||
|
|
||||||
|
upstream = git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||||
|
upstream_name = upstream.stdout.strip() if upstream.returncode == 0 else None
|
||||||
|
behind: int | None = None
|
||||||
|
ahead: int | None = None
|
||||||
|
if upstream_name is None:
|
||||||
|
errors.append("current branch has no upstream")
|
||||||
|
else:
|
||||||
|
counts = git("rev-list", "--left-right", "--count", "@{u}...HEAD")
|
||||||
|
try:
|
||||||
|
behind, ahead = (int(value) for value in counts.stdout.split())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
errors.append(counts.stderr.strip() or "could not compare HEAD with upstream")
|
||||||
|
else:
|
||||||
|
if behind or ahead:
|
||||||
|
errors.append("branch does not exactly match its upstream")
|
||||||
|
|
||||||
|
remote = git("remote", "get-url", "origin")
|
||||||
|
origin = remote.stdout.strip() if remote.returncode == 0 else None
|
||||||
|
if origin is None:
|
||||||
|
errors.append("origin remote is missing")
|
||||||
|
elif any(
|
||||||
|
marker in origin
|
||||||
|
for marker in ("gitea-remote", "gitea.coulomb.social", "92.205.130.254")
|
||||||
|
):
|
||||||
|
errors.append("origin targets the retired Gitea lineage")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": not errors,
|
||||||
|
"head_sha": current_head,
|
||||||
|
"expected_head_sha": expected_head_sha,
|
||||||
|
"upstream": upstream_name,
|
||||||
|
"behind": behind,
|
||||||
|
"ahead": ahead,
|
||||||
|
"origin": origin,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def plan_identifier_migration_batch(
|
||||||
|
plan: dict[str, Any], *, repo_slugs: list[str]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Pin a reviewed repository batch after source and Git preflight.
|
||||||
|
|
||||||
|
The result is an approval package, not apply authority. Live database and
|
||||||
|
file mutation still require an explicit decision citing ``batch_sha256``.
|
||||||
|
"""
|
||||||
|
plan_sha256 = _verified_plan_seal(plan)
|
||||||
|
requested = [slug.strip() for slug in repo_slugs if slug.strip()]
|
||||||
|
errors: list[dict[str, str]] = []
|
||||||
|
if not requested:
|
||||||
|
errors.append({"scope": "batch", "reason": "at least one repository is required"})
|
||||||
|
if len(set(requested)) != len(requested):
|
||||||
|
errors.append({"scope": "batch", "reason": "repository list contains duplicates"})
|
||||||
|
|
||||||
|
repositories_by_slug: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for repository in plan.get("repositories", []):
|
||||||
|
repositories_by_slug[str(repository.get("repo") or "")].append(repository)
|
||||||
|
|
||||||
|
batch_repositories: list[dict[str, Any]] = []
|
||||||
|
totals = {"repositories": 0, "records": 0, "replace": 0, "assign": 0, "unchanged": 0}
|
||||||
|
for slug in requested:
|
||||||
|
matches = repositories_by_slug.get(slug, [])
|
||||||
|
if len(matches) != 1:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"scope": slug,
|
||||||
|
"reason": "repository must occur exactly once in the source plan",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
repository = matches[0]
|
||||||
|
source_verification = verify_identifier_migration_plan(plan, repo_slug=slug)
|
||||||
|
repo = Path(str(repository.get("path") or "")).resolve()
|
||||||
|
git_preflight = _git_cutover_preflight(
|
||||||
|
repo,
|
||||||
|
expected_head_sha=repository.get("planned_head_sha"),
|
||||||
|
)
|
||||||
|
mappings = list(repository.get("mappings") or [])
|
||||||
|
action_counts = {
|
||||||
|
action: sum(mapping.get("action") == action for mapping in mappings)
|
||||||
|
for action in ("replace", "assign", "unchanged")
|
||||||
|
}
|
||||||
|
actionable = action_counts["replace"] + action_counts["assign"]
|
||||||
|
if actionable == 0:
|
||||||
|
errors.append({"scope": slug, "reason": "repository has no migration action"})
|
||||||
|
for error in source_verification["errors"]:
|
||||||
|
errors.append({"scope": slug, "reason": error["reason"]})
|
||||||
|
for reason in git_preflight["errors"]:
|
||||||
|
errors.append({"scope": slug, "reason": reason})
|
||||||
|
|
||||||
|
batch_repositories.append(
|
||||||
|
{
|
||||||
|
"repo": slug,
|
||||||
|
"path": str(repo),
|
||||||
|
"planned_head_sha": repository.get("planned_head_sha"),
|
||||||
|
"source_fingerprint": repository.get("source_fingerprint"),
|
||||||
|
"source_verified": source_verification["ok"],
|
||||||
|
"git_preflight": git_preflight,
|
||||||
|
"mapping_counts": {"records": len(mappings), **action_counts},
|
||||||
|
"ready": source_verification["ok"] and git_preflight["ok"] and actionable > 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
totals["repositories"] += 1
|
||||||
|
totals["records"] += len(mappings)
|
||||||
|
for action, count in action_counts.items():
|
||||||
|
totals[action] += count
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"schema": "repo-manager.identifier-migration-batch.v1",
|
||||||
|
"ok": not errors,
|
||||||
|
"ready_for_approval": not errors,
|
||||||
|
"apply_authorized": False,
|
||||||
|
"approval_required": True,
|
||||||
|
"namespace": plan.get("namespace"),
|
||||||
|
"source_plan_sha256": plan_sha256,
|
||||||
|
"batch_policy": "repository-atomic, sequential, stop on first failure",
|
||||||
|
"rollback_order": "reverse files if written, then reverse central projection",
|
||||||
|
"generated_at": utc_now_text(),
|
||||||
|
"totals": totals,
|
||||||
|
"repositories": batch_repositories,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
canonical = json.dumps(result, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
result["batch_sha256"] = hashlib.sha256(canonical).hexdigest()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def verify_identifier_migration_batch(
|
||||||
|
batch: dict[str, Any], *, plan: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Verify a saved batch seal and repeat every selected source/Git check."""
|
||||||
|
errors: list[dict[str, str]] = []
|
||||||
|
expected_batch_sha256 = batch.get("batch_sha256")
|
||||||
|
unsealed = {key: value for key, value in batch.items() if key != "batch_sha256"}
|
||||||
|
canonical = json.dumps(unsealed, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
actual_batch_sha256 = hashlib.sha256(canonical).hexdigest()
|
||||||
|
if batch.get("schema") != "repo-manager.identifier-migration-batch.v1":
|
||||||
|
errors.append({"scope": "batch", "reason": "unsupported batch schema"})
|
||||||
|
if expected_batch_sha256 != actual_batch_sha256:
|
||||||
|
errors.append({"scope": "batch", "reason": "batch SHA-256 mismatch"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan_sha256 = _verified_plan_seal(plan)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
errors.append({"scope": "plan", "reason": str(exc)})
|
||||||
|
plan_sha256 = None
|
||||||
|
if batch.get("source_plan_sha256") != plan_sha256:
|
||||||
|
errors.append({"scope": "plan", "reason": "batch source plan SHA-256 mismatch"})
|
||||||
|
if batch.get("apply_authorized") is not False or batch.get("approval_required") is not True:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"scope": "batch",
|
||||||
|
"reason": "batch must remain unauthorized and approval-required",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_repositories = list(batch.get("repositories") or [])
|
||||||
|
repo_slugs = [str(item.get("repo") or "") for item in saved_repositories]
|
||||||
|
if not repo_slugs or any(not slug for slug in repo_slugs):
|
||||||
|
errors.append({"scope": "batch", "reason": "batch has no valid repository scope"})
|
||||||
|
fresh = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
fresh = plan_identifier_migration_batch(plan, repo_slugs=repo_slugs)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
errors.append({"scope": "batch", "reason": str(exc)})
|
||||||
|
fresh = None
|
||||||
|
|
||||||
|
if fresh is not None:
|
||||||
|
errors.extend(fresh["errors"])
|
||||||
|
fresh_by_slug = {item["repo"]: item for item in fresh["repositories"]}
|
||||||
|
for saved in saved_repositories:
|
||||||
|
slug = str(saved.get("repo") or "")
|
||||||
|
current = fresh_by_slug.get(slug)
|
||||||
|
if current is None:
|
||||||
|
errors.append({"scope": slug, "reason": "repository disappeared from batch"})
|
||||||
|
continue
|
||||||
|
for field in ("planned_head_sha", "source_fingerprint", "mapping_counts"):
|
||||||
|
if saved.get(field) != current.get(field):
|
||||||
|
errors.append({"scope": slug, "reason": f"saved {field} differs from plan"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema": "repo-manager.identifier-migration-batch-verification.v1",
|
||||||
|
"ok": not errors,
|
||||||
|
"ready_for_decision": not errors,
|
||||||
|
"apply_authorized": False,
|
||||||
|
"approval_required": True,
|
||||||
|
"batch_sha256": expected_batch_sha256,
|
||||||
|
"source_plan_sha256": plan_sha256,
|
||||||
|
"repositories": repo_slugs,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _verified_plan_seal(plan: dict[str, Any]) -> str:
|
def _verified_plan_seal(plan: dict[str, Any]) -> str:
|
||||||
if plan.get("schema") != "repo-manager.identifier-migration-plan.v1":
|
if plan.get("schema") != "repo-manager.identifier-migration-plan.v1":
|
||||||
raise ValueError("unsupported identifier migration plan schema")
|
raise ValueError("unsupported identifier migration plan schema")
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ from repo_manager.identifiers import (
|
||||||
load_fleet_namespace,
|
load_fleet_namespace,
|
||||||
migrate_repository_identifier_files,
|
migrate_repository_identifier_files,
|
||||||
plan_identifier_migration,
|
plan_identifier_migration,
|
||||||
|
plan_identifier_migration_batch,
|
||||||
scan_live_identifier_collisions,
|
scan_live_identifier_collisions,
|
||||||
|
verify_identifier_migration_batch,
|
||||||
verify_identifier_migration_plan,
|
verify_identifier_migration_plan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -203,3 +205,84 @@ def test_identifier_file_migration_requires_exact_plan_confirmation(tmp_path: Pa
|
||||||
repo_slug="one",
|
repo_slug="one",
|
||||||
confirm_plan_sha256="0" * 64,
|
confirm_plan_sha256="0" * 64,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _push_fixture_to_upstream(repo: Path, remote: Path) -> None:
|
||||||
|
subprocess.run(["git", "init", "--bare", remote], check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "remote", "add", "origin", str(remote)], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "push", "-u", "origin", "HEAD:main"], cwd=repo, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_batch_plan_pins_clean_synchronized_repo(tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "one"
|
||||||
|
path = repo / "workplans" / "one.md"
|
||||||
|
_workplan(path, "ONE-WP-0001", "active")
|
||||||
|
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed"],
|
||||||
|
cwd=repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
_push_fixture_to_upstream(repo, tmp_path / "remote.git")
|
||||||
|
plan = plan_identifier_migration(tmp_path, "helixforge")
|
||||||
|
|
||||||
|
batch = plan_identifier_migration_batch(plan, repo_slugs=["one"])
|
||||||
|
|
||||||
|
assert batch["ok"] is True
|
||||||
|
assert batch["ready_for_approval"] is True
|
||||||
|
assert batch["apply_authorized"] is False
|
||||||
|
assert batch["approval_required"] is True
|
||||||
|
assert batch["repositories"][0]["git_preflight"]["upstream"] == "origin/main"
|
||||||
|
assert len(batch["batch_sha256"]) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_batch_plan_rejects_dirty_or_duplicate_scope(tmp_path: Path) -> None:
|
||||||
|
repo = tmp_path / "one"
|
||||||
|
path = repo / "workplans" / "one.md"
|
||||||
|
_workplan(path, "ONE-WP-0001", "active")
|
||||||
|
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed"],
|
||||||
|
cwd=repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
_push_fixture_to_upstream(repo, tmp_path / "remote.git")
|
||||||
|
plan = plan_identifier_migration(tmp_path, "helixforge")
|
||||||
|
path.write_text(path.read_text(encoding="utf-8") + "\ndrift\n", encoding="utf-8")
|
||||||
|
|
||||||
|
batch = plan_identifier_migration_batch(plan, repo_slugs=["one", "one"])
|
||||||
|
|
||||||
|
assert batch["ok"] is False
|
||||||
|
assert batch["ready_for_approval"] is False
|
||||||
|
assert any(error["reason"] == "repository list contains duplicates" for error in batch["errors"])
|
||||||
|
assert any(error["reason"] == "worktree is not clean" for error in batch["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_batch_verification_repeats_preflight_and_detects_tampering(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repo = tmp_path / "one"
|
||||||
|
path = repo / "workplans" / "one.md"
|
||||||
|
_workplan(path, "ONE-WP-0001", "active")
|
||||||
|
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed"],
|
||||||
|
cwd=repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
_push_fixture_to_upstream(repo, tmp_path / "remote.git")
|
||||||
|
plan = plan_identifier_migration(tmp_path, "helixforge")
|
||||||
|
batch = plan_identifier_migration_batch(plan, repo_slugs=["one"])
|
||||||
|
|
||||||
|
assert verify_identifier_migration_batch(batch, plan=plan)["ok"] is True
|
||||||
|
|
||||||
|
batch["totals"]["records"] = 999
|
||||||
|
verification = verify_identifier_migration_batch(batch, plan=plan)
|
||||||
|
assert verification["ok"] is False
|
||||||
|
assert any(error["reason"] == "batch SHA-256 mismatch" for error in verification["errors"])
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,23 @@ evidence, restore points, hashes, and residuals are in
|
||||||
progress: widening beyond this single repository requires a new explicit fleet
|
progress: widening beyond this single repository requires a new explicit fleet
|
||||||
cutover decision and refreshed source fingerprints.
|
cutover decision and refreshed source fingerprints.
|
||||||
|
|
||||||
|
**Batch 0002 prepared (2026-08-22):** the original fleet seal now rejects 15
|
||||||
|
repositories with legitimate source drift. A refreshed zero-collision plan
|
||||||
|
covers 41 repositories and 230 records. New `rmgr identifier
|
||||||
|
migration-batch-plan` pins an exact subset only after source verification plus
|
||||||
|
clean, Forgejo-synchronized Git preflight, and always emits
|
||||||
|
`apply_authorized: false` until a decision cites its batch hash.
|
||||||
|
|
||||||
|
`RMGR-DEC-2026-001` now offers the directly executable second pilot:
|
||||||
|
`whynot-design` only, one workplan UUID replacement, no task replacements or
|
||||||
|
assignments. Workstation and production both resolve the old UUID, reject the
|
||||||
|
derived UUID, and agree on 9 tasks, 11 progress events, and 1 decision. The
|
||||||
|
sealed batch hash is
|
||||||
|
`4039224352c6590fdc6b41e739539f1f9c92d098b43ed6e30b3cd9a87b53da65`.
|
||||||
|
No mutation has run; approval, revision, or rejection of that exact package is
|
||||||
|
the remaining gate. Evidence:
|
||||||
|
`docs/evidence/RMGR-WP-0005-batch-0002-readiness-2026-08-22.md`.
|
||||||
|
|
||||||
## Retire the interim rule
|
## Retire the interim rule
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue