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 |
|
||||
| intake | RMGR-IN-0001 | 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-verify --plan uuid-migration.json
|
||||
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
|
||||
|
|
@ -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
|
||||
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.
|
||||
Publishing or planning this function does not retroactively rewrite existing
|
||||
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("--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(
|
||||
"migration-files",
|
||||
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,
|
||||
migrate_repository_identifier_files,
|
||||
plan_identifier_migration,
|
||||
plan_identifier_migration_batch,
|
||||
scan_live_identifier_collisions,
|
||||
verify_identifier_migration_batch,
|
||||
verify_identifier_migration_plan,
|
||||
)
|
||||
|
||||
|
|
@ -730,6 +746,37 @@ def main(argv: list[str] | None = None) -> int:
|
|||
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
||||
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:
|
||||
try:
|
||||
result = plan_identifier_migration(Path(args.root), namespace)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import hashlib
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
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:
|
||||
if plan.get("schema") != "repo-manager.identifier-migration-plan.v1":
|
||||
raise ValueError("unsupported identifier migration plan schema")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ from repo_manager.identifiers import (
|
|||
load_fleet_namespace,
|
||||
migrate_repository_identifier_files,
|
||||
plan_identifier_migration,
|
||||
plan_identifier_migration_batch,
|
||||
scan_live_identifier_collisions,
|
||||
verify_identifier_migration_batch,
|
||||
verify_identifier_migration_plan,
|
||||
)
|
||||
|
||||
|
|
@ -203,3 +205,84 @@ def test_identifier_file_migration_requires_exact_plan_confirmation(tmp_path: Pa
|
|||
repo_slug="one",
|
||||
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
|
||||
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
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue