feat(identifiers): verify batch projections
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
e6cc18bf18
commit
055c6971ab
11 changed files with 2964 additions and 9 deletions
|
|
@ -0,0 +1,55 @@
|
|||
# RMGR-WP-0005 batch 0005 projection preflight
|
||||
|
||||
**Observed at:** `2026-08-22T21:46:30Z`
|
||||
|
||||
**Scope:** `adaptive-pricing` only
|
||||
|
||||
## Outcome
|
||||
|
||||
Batch 0005 is source-ready but **not ready for approval**. No database or
|
||||
repository identifier changed.
|
||||
|
||||
The refreshed fleet plan is sealed as
|
||||
`28cd01bdbc66c53ad5ef11b9821a74ebadd7c8cfdae4c227f37ce7f9d67cbf18`.
|
||||
The adaptive-pricing unit is clean, synchronized with `origin/main`, and pinned
|
||||
at `c65a2f1ff9a0b30700ee14818e36e931eb71cae3`. It contains five replacements:
|
||||
`ADAPTIVE-WP-0010` and its four unfinished tasks.
|
||||
|
||||
The projection-aware batch manifest is sealed as
|
||||
`20decab5521ea5477826d1b2a9c9670912a96c4b8245faf5d270c8d1efc988c5`.
|
||||
It records `ready_for_approval: false` and `apply_authorized: false`.
|
||||
|
||||
## Projection evidence
|
||||
|
||||
| Projection | Five current UUIDs | Five derived UUIDs | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| Workstation `:8000` | HTTP 200 | HTTP 404 | ready |
|
||||
| Production tunnel `:18000` | HTTP 404 | HTTP 404 | blocked |
|
||||
|
||||
A governed `registrar-reconcile --repair-workplan ADAPTIVE-WP-0010` attempt
|
||||
used clean source revision `c65a2f1...` and correlation id
|
||||
`b708a73e-fc09-48ee-aa2d-ff8b3c642466`. State Hub retained the conservative
|
||||
C-03 rule for the pre-derivation random UUID and did not create it. This is the
|
||||
correct ordinary-consistency behavior, but it means the existing transactional
|
||||
migration executor cannot run: that executor requires every old source row to
|
||||
exist in the target repository projection.
|
||||
|
||||
The attempt also exposed a Repo Manager result bug: a failed exact repair could
|
||||
be labeled `applied` because an empty ordinary-registration set passed
|
||||
verification vacuously. The wrapper now requires repair/bootstrap mode's own
|
||||
exact projection proof and does not run ordinary requested-record verification
|
||||
for an empty request.
|
||||
|
||||
## Owner-ready resolution
|
||||
|
||||
`helixforge.identifiers.state-hub-sealed-projection-repair.v1` offers State Hub
|
||||
a bounded repair task. It preserves the default C-03 refusal and permits old-row
|
||||
restoration only from one clean source revision plus one exact sealed migration
|
||||
unit, with primary confirmation, atomicity, idempotency, and a receipt. Once
|
||||
adopted, repeat this batch plan against both projections; only a new manifest
|
||||
with all current lookups 200 and all derived lookups 404 may be proposed for
|
||||
approval.
|
||||
|
||||
The fleet scan found one other projection-complete candidate, `state-hub`, but
|
||||
its checkout contains unrelated uncommitted owner evidence. The Git preflight
|
||||
therefore excludes it until the owner publishes or dispositions that work.
|
||||
182
docs/evidence/RMGR-WP-0005-batch-0005-adaptive-pricing.json
Normal file
182
docs/evidence/RMGR-WP-0005-batch-0005-adaptive-pricing.json
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
{
|
||||
"schema": "repo-manager.identifier-migration-batch.v1",
|
||||
"ok": false,
|
||||
"ready_for_approval": false,
|
||||
"apply_authorized": false,
|
||||
"approval_required": true,
|
||||
"projection_api_bases": [
|
||||
"http://127.0.0.1:8000",
|
||||
"http://127.0.0.1:18000"
|
||||
],
|
||||
"namespace": "helixforge",
|
||||
"source_plan_sha256": "28cd01bdbc66c53ad5ef11b9821a74ebadd7c8cfdae4c227f37ce7f9d67cbf18",
|
||||
"batch_policy": "repository-atomic, sequential, stop on first failure",
|
||||
"rollback_order": "reverse files if written, then reverse central projection",
|
||||
"generated_at": "2026-08-22T21:45:54.917855Z",
|
||||
"totals": {
|
||||
"repositories": 1,
|
||||
"records": 5,
|
||||
"replace": 5,
|
||||
"assign": 0,
|
||||
"unchanged": 0
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"repo": "adaptive-pricing",
|
||||
"path": "/home/worsch/adaptive-pricing",
|
||||
"planned_head_sha": "c65a2f1ff9a0b30700ee14818e36e931eb71cae3",
|
||||
"source_fingerprint": "977e94daeb0a6955a3d4ba76393d0b273d285537d0789eb59350d5c283901887",
|
||||
"source_verified": true,
|
||||
"git_preflight": {
|
||||
"ok": true,
|
||||
"head_sha": "c65a2f1ff9a0b30700ee14818e36e931eb71cae3",
|
||||
"expected_head_sha": "c65a2f1ff9a0b30700ee14818e36e931eb71cae3",
|
||||
"upstream": "origin/main",
|
||||
"behind": 0,
|
||||
"ahead": 0,
|
||||
"origin": "forgejo-remote:coulomb/adaptive-pricing.git",
|
||||
"errors": []
|
||||
},
|
||||
"projection_preflight": {
|
||||
"required": true,
|
||||
"ok": false,
|
||||
"projections": [
|
||||
{
|
||||
"api_base": "http://127.0.0.1:8000",
|
||||
"ok": true,
|
||||
"replacement_checks": [
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010",
|
||||
"kind": "workplan",
|
||||
"current_status": 200,
|
||||
"derived_status": 404,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010-T01",
|
||||
"kind": "task",
|
||||
"current_status": 200,
|
||||
"derived_status": 404,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010-T02",
|
||||
"kind": "task",
|
||||
"current_status": 200,
|
||||
"derived_status": 404,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010-T03",
|
||||
"kind": "task",
|
||||
"current_status": 200,
|
||||
"derived_status": 404,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010-T04",
|
||||
"kind": "task",
|
||||
"current_status": 200,
|
||||
"derived_status": 404,
|
||||
"ok": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"api_base": "http://127.0.0.1:18000",
|
||||
"ok": false,
|
||||
"replacement_checks": [
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010",
|
||||
"kind": "workplan",
|
||||
"current_status": 404,
|
||||
"derived_status": 404,
|
||||
"ok": false
|
||||
},
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010-T01",
|
||||
"kind": "task",
|
||||
"current_status": 404,
|
||||
"derived_status": 404,
|
||||
"ok": false
|
||||
},
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010-T02",
|
||||
"kind": "task",
|
||||
"current_status": 404,
|
||||
"derived_status": 404,
|
||||
"ok": false
|
||||
},
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010-T03",
|
||||
"kind": "task",
|
||||
"current_status": 404,
|
||||
"derived_status": 404,
|
||||
"ok": false
|
||||
},
|
||||
{
|
||||
"record_id": "ADAPTIVE-WP-0010-T04",
|
||||
"kind": "task",
|
||||
"current_status": 404,
|
||||
"derived_status": 404,
|
||||
"ok": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": [
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
},
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010-T01",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
},
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010-T02",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
},
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010-T03",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
},
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010-T04",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mapping_counts": {
|
||||
"records": 5,
|
||||
"replace": 5,
|
||||
"assign": 0,
|
||||
"unchanged": 0
|
||||
},
|
||||
"ready": false
|
||||
}
|
||||
],
|
||||
"errors": [
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
},
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010-T01",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
},
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010-T02",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
},
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010-T03",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
},
|
||||
{
|
||||
"scope": "http://127.0.0.1:18000:ADAPTIVE-WP-0010-T04",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404"
|
||||
}
|
||||
],
|
||||
"batch_sha256": "20decab5521ea5477826d1b2a9c9670912a96c4b8245faf5d270c8d1efc988c5"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -47,6 +47,8 @@ rmgr identifier migration-verify --plan uuid-migration.json --repo repo-manager
|
|||
rmgr identifier migration-batch-plan \
|
||||
--plan uuid-migration.json \
|
||||
--repo whynot-design \
|
||||
--projection-api-base http://127.0.0.1:8000 \
|
||||
--projection-api-base http://127.0.0.1:18000 \
|
||||
--output batch.json
|
||||
rmgr identifier migration-batch-verify \
|
||||
--plan uuid-migration.json \
|
||||
|
|
@ -73,7 +75,12 @@ 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.
|
||||
any database or file mutation. Repeat `--projection-api-base` for every hub in
|
||||
the cutover: each replacement must resolve its current UUID with HTTP 200 and
|
||||
its derived target with HTTP 404 on every named projection, or the manifest is
|
||||
not ready for approval. Saved projection endpoints are rechecked by
|
||||
`migration-batch-verify`; omitting the option retains the offline source/Git-only
|
||||
planning mode.
|
||||
|
||||
Activation and applying a bulk migration remain separate governed steps.
|
||||
Publishing or planning this function does not retroactively rewrite existing
|
||||
|
|
|
|||
62
interfaces/state-hub-sealed-projection-repair-v1.yaml
Normal file
62
interfaces/state-hub-sealed-projection-repair-v1.yaml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
apiVersion: helixforge.repo-manager/v1
|
||||
kind: OwnerTaskInterface
|
||||
metadata:
|
||||
id: helixforge.identifiers.state-hub-sealed-projection-repair.v1
|
||||
title: Repair an absent pre-derivation projection from a sealed migration unit
|
||||
source:
|
||||
repo: repo-manager
|
||||
workplan_task: RMGR-WP-0005-T04
|
||||
target:
|
||||
repo: state-hub
|
||||
owner_agent: state-hub
|
||||
suggested_work_record: STATE-WP-0079 follow-up task or owner-selected workplan task
|
||||
approval:
|
||||
dispositions: [approved, amended, rejected]
|
||||
record_in: State Hub file-backed workplan task or decision
|
||||
task:
|
||||
title: Add sealed repair for absent pre-derivation projection rows
|
||||
priority: high
|
||||
objective: >-
|
||||
Allow an authoritative random UUID that predates deterministic derivation to
|
||||
be restored into one partial projection only when a sealed Repo Manager
|
||||
migration unit proves the exact repository, record identity, old UUID, and
|
||||
derived target, so both projections can enter the existing transactional
|
||||
cutover with matching source rows.
|
||||
inputs:
|
||||
- ref: repo-manager/docs/work-record-uuid-derivation_v1.md
|
||||
authority: repo-manager
|
||||
- ref: repo-manager/src/repo_manager/commands/registrar_reconcile.py
|
||||
authority: repo-manager
|
||||
- ref: state-hub/api/services/work_record_identifier_migration.py
|
||||
authority: state-hub
|
||||
deliverables:
|
||||
- >-
|
||||
Add an explicit registrar-only repair mode scoped to one canonical
|
||||
workplan and one sealed repository migration unit; never widen ordinary
|
||||
consistency repair for arbitrary random stale UUIDs.
|
||||
- >-
|
||||
Reconstruct the absent workplan and its mapped tasks with their exact old
|
||||
UUIDs from the clean, synchronized authoritative file and existing
|
||||
repository/topic projection, then verify exact API visibility.
|
||||
- >-
|
||||
Emit a non-secret receipt containing repository, canonical ids, old UUIDs,
|
||||
plan seal, source Git revision, projection identity, and canonical UTC
|
||||
observation time.
|
||||
- Make retry idempotent and reject partial presence, target presence, source drift, or UUID conflict.
|
||||
constraints:
|
||||
- The normal C-03 random stale-reference refusal remains unchanged.
|
||||
- Repair requires explicit primary confirmation and an exact sealed plan hash.
|
||||
- Do not infer a repository, topic, canonical id, or UUID from path strings alone.
|
||||
- Do not mutate the authoritative workplan file during projection repair.
|
||||
- A failed repair must leave no partially created workplan or task rows.
|
||||
acceptance:
|
||||
- >-
|
||||
A fixture with the old row present on one hub and absent on another repairs
|
||||
only the absent projection and then passes current UUID=200 and derived UUID=404 preflight.
|
||||
- A random stale UUID outside the sealed unit remains non-fixable C-03.
|
||||
- Repeating the same repair returns a verified no-op receipt.
|
||||
- A dirty or unsynchronized source, mismatched plan hash, or partially present unit is rejected.
|
||||
- Existing empty-projection bootstrap and derived-row repair behavior remain unchanged.
|
||||
verification:
|
||||
- uv run pytest -q tests/test_consistency_check.py tests/test_work_record_identifier_migration.py
|
||||
- make test
|
||||
|
|
@ -318,6 +318,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||
)
|
||||
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(
|
||||
"--projection-api-base",
|
||||
action="append",
|
||||
default=[],
|
||||
dest="projection_api_bases",
|
||||
help="Require current UUID=200 and derived UUID=404 on this projection",
|
||||
)
|
||||
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(
|
||||
|
|
@ -772,7 +779,11 @@ 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 = plan_identifier_migration_batch(plan, repo_slugs=args.repos)
|
||||
result = plan_identifier_migration_batch(
|
||||
plan,
|
||||
repo_slugs=args.repos,
|
||||
projection_api_bases=args.projection_api_bases,
|
||||
)
|
||||
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -551,7 +551,7 @@ def registrar_reconcile(
|
|||
bootstrap_verified = projection_error is None
|
||||
|
||||
requested_verified = False
|
||||
if completed.returncode == 1 and not any(after.values()):
|
||||
if completed.returncode == 1 and any(before.values()) and not any(after.values()):
|
||||
(
|
||||
requested_workplans,
|
||||
requested_tasks,
|
||||
|
|
@ -578,7 +578,14 @@ def registrar_reconcile(
|
|||
# A repository-scoped projection repair may coexist with legacy stale
|
||||
# references that correctly keep the broader consistency report red.
|
||||
accepted_exit_codes.add(1)
|
||||
if completed.returncode not in accepted_exit_codes or any(after.values()):
|
||||
mode_verification_failed = bool(repair_projection_id and not repair_verified) or (
|
||||
bootstrap_empty_projection and not bootstrap_verified
|
||||
)
|
||||
if (
|
||||
completed.returncode not in accepted_exit_codes
|
||||
or any(after.values())
|
||||
or mode_verification_failed
|
||||
):
|
||||
return RegistrarResult(
|
||||
"failed",
|
||||
evidence,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from collections import defaultdict
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from repo_manager.cache import source_fingerprint
|
||||
|
|
@ -359,8 +360,79 @@ def _git_cutover_preflight(repo: Path, *, expected_head_sha: str | None) -> dict
|
|||
}
|
||||
|
||||
|
||||
def _projection_migration_preflight(
|
||||
mappings: list[dict[str, Any]], api_bases: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Require every replacement source and no replacement target per projection."""
|
||||
projections: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for raw_base in api_bases:
|
||||
api_base = raw_base.strip().rstrip("/")
|
||||
try:
|
||||
url = httpx.URL(api_base)
|
||||
if (
|
||||
url.scheme not in {"http", "https"}
|
||||
or not url.host
|
||||
or url.username
|
||||
or url.password
|
||||
or url.query
|
||||
or url.fragment
|
||||
):
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
errors.append(
|
||||
{"scope": raw_base, "reason": "projection API base must be a plain HTTP(S) origin"}
|
||||
)
|
||||
continue
|
||||
|
||||
checks: list[dict[str, Any]] = []
|
||||
try:
|
||||
with httpx.Client(timeout=10.0, follow_redirects=False) as client:
|
||||
for mapping in mappings:
|
||||
if mapping.get("action") != "replace":
|
||||
continue
|
||||
route = "workplans" if mapping.get("kind") == "workplan" else "tasks"
|
||||
current = client.get(f"{api_base}/{route}/{mapping.get('current_uuid')}")
|
||||
derived = client.get(f"{api_base}/{route}/{mapping.get('derived_uuid')}")
|
||||
check_ok = current.status_code == 200 and derived.status_code == 404
|
||||
check = {
|
||||
"record_id": mapping.get("record_id"),
|
||||
"kind": mapping.get("kind"),
|
||||
"current_status": current.status_code,
|
||||
"derived_status": derived.status_code,
|
||||
"ok": check_ok,
|
||||
}
|
||||
checks.append(check)
|
||||
if not check_ok:
|
||||
errors.append(
|
||||
{
|
||||
"scope": f"{api_base}:{mapping.get('record_id')}",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404",
|
||||
}
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
errors.append({"scope": api_base, "reason": "projection API is unavailable"})
|
||||
projections.append(
|
||||
{
|
||||
"api_base": api_base,
|
||||
"ok": all(check["ok"] for check in checks),
|
||||
"replacement_checks": checks,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"required": True,
|
||||
"ok": bool(projections) and not errors,
|
||||
"projections": projections,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def plan_identifier_migration_batch(
|
||||
plan: dict[str, Any], *, repo_slugs: list[str]
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
repo_slugs: list[str],
|
||||
projection_api_bases: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Pin a reviewed repository batch after source and Git preflight.
|
||||
|
||||
|
|
@ -379,6 +451,7 @@ def plan_identifier_migration_batch(
|
|||
for repository in plan.get("repositories", []):
|
||||
repositories_by_slug[str(repository.get("repo") or "")].append(repository)
|
||||
|
||||
projection_api_bases = list(projection_api_bases or [])
|
||||
batch_repositories: list[dict[str, Any]] = []
|
||||
totals = {"repositories": 0, "records": 0, "replace": 0, "assign": 0, "unchanged": 0}
|
||||
for slug in requested:
|
||||
|
|
@ -410,6 +483,12 @@ def plan_identifier_migration_batch(
|
|||
errors.append({"scope": slug, "reason": error["reason"]})
|
||||
for reason in git_preflight["errors"]:
|
||||
errors.append({"scope": slug, "reason": reason})
|
||||
projection_preflight = None
|
||||
if projection_api_bases:
|
||||
projection_preflight = _projection_migration_preflight(
|
||||
mappings, projection_api_bases
|
||||
)
|
||||
errors.extend(projection_preflight["errors"])
|
||||
|
||||
batch_repositories.append(
|
||||
{
|
||||
|
|
@ -419,8 +498,14 @@ def plan_identifier_migration_batch(
|
|||
"source_fingerprint": repository.get("source_fingerprint"),
|
||||
"source_verified": source_verification["ok"],
|
||||
"git_preflight": git_preflight,
|
||||
"projection_preflight": projection_preflight,
|
||||
"mapping_counts": {"records": len(mappings), **action_counts},
|
||||
"ready": source_verification["ok"] and git_preflight["ok"] and actionable > 0,
|
||||
"ready": (
|
||||
source_verification["ok"]
|
||||
and git_preflight["ok"]
|
||||
and actionable > 0
|
||||
and (projection_preflight is None or projection_preflight["ok"])
|
||||
),
|
||||
}
|
||||
)
|
||||
totals["repositories"] += 1
|
||||
|
|
@ -434,6 +519,7 @@ def plan_identifier_migration_batch(
|
|||
"ready_for_approval": not errors,
|
||||
"apply_authorized": False,
|
||||
"approval_required": True,
|
||||
"projection_api_bases": [base.strip().rstrip("/") for base in projection_api_bases],
|
||||
"namespace": plan.get("namespace"),
|
||||
"source_plan_sha256": plan_sha256,
|
||||
"batch_policy": "repository-atomic, sequential, stop on first failure",
|
||||
|
|
@ -484,7 +570,11 @@ def verify_identifier_migration_batch(
|
|||
fresh = None
|
||||
else:
|
||||
try:
|
||||
fresh = plan_identifier_migration_batch(plan, repo_slugs=repo_slugs)
|
||||
fresh = plan_identifier_migration_batch(
|
||||
plan,
|
||||
repo_slugs=repo_slugs,
|
||||
projection_api_bases=list(batch.get("projection_api_bases") or []),
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
errors.append({"scope": "batch", "reason": str(exc)})
|
||||
fresh = None
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from repo_manager.identifiers import (
|
||||
|
|
@ -213,10 +214,24 @@ def _push_fixture_to_upstream(repo: Path, remote: Path) -> None:
|
|||
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:
|
||||
def test_migration_batch_plan_pins_clean_synchronized_repo(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
repo = tmp_path / "one"
|
||||
path = repo / "workplans" / "one.md"
|
||||
_workplan(path, "ONE-WP-0001", "active")
|
||||
path.write_text(
|
||||
path.read_text(encoding="utf-8")
|
||||
.replace(
|
||||
"status: active\n---",
|
||||
'status: active\nstate_hub_workstream_id: "11111111-1111-4111-8111-111111111111"\n---',
|
||||
)
|
||||
.replace(
|
||||
"status: todo\n```",
|
||||
'status: todo\nstate_hub_task_id: "22222222-2222-4222-8222-222222222222"\n```',
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
|
|
@ -228,15 +243,84 @@ def test_migration_batch_plan_pins_clean_synchronized_repo(tmp_path: Path) -> No
|
|||
_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"])
|
||||
current_uuids = {
|
||||
mapping["current_uuid"] for mapping in plan["repositories"][0]["mappings"]
|
||||
}
|
||||
|
||||
def projection_get(self, url):
|
||||
status = 200 if any(str(url).endswith(str(value)) for value in current_uuids) else 404
|
||||
return httpx.Response(status, request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr(httpx.Client, "get", projection_get)
|
||||
batch = plan_identifier_migration_batch(
|
||||
plan,
|
||||
repo_slugs=["one"],
|
||||
projection_api_bases=["http://hub-one.test", "http://hub-two.test"],
|
||||
)
|
||||
|
||||
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["projection_api_bases"] == [
|
||||
"http://hub-one.test",
|
||||
"http://hub-two.test",
|
||||
]
|
||||
assert batch["repositories"][0]["projection_preflight"]["ok"] is True
|
||||
assert batch["repositories"][0]["git_preflight"]["upstream"] == "origin/main"
|
||||
assert len(batch["batch_sha256"]) == 64
|
||||
|
||||
def projection_drift(self, url):
|
||||
return httpx.Response(404, request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr(httpx.Client, "get", projection_drift)
|
||||
verification = verify_identifier_migration_batch(batch, plan=plan)
|
||||
assert verification["ok"] is False
|
||||
assert verification["ready_for_decision"] is False
|
||||
|
||||
|
||||
def test_migration_batch_plan_rejects_projection_gap(tmp_path: Path, monkeypatch) -> None:
|
||||
repo = tmp_path / "one"
|
||||
path = repo / "workplans" / "one.md"
|
||||
_workplan(path, "ONE-WP-0001", "active")
|
||||
path.write_text(
|
||||
path.read_text(encoding="utf-8")
|
||||
.replace(
|
||||
"status: active\n---",
|
||||
'status: active\nstate_hub_workstream_id: "11111111-1111-4111-8111-111111111111"\n---',
|
||||
)
|
||||
.replace(
|
||||
"status: todo\n```",
|
||||
'status: todo\nstate_hub_task_id: "22222222-2222-4222-8222-222222222222"\n```',
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
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")
|
||||
|
||||
def projection_get(self, url):
|
||||
return httpx.Response(404, request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr(httpx.Client, "get", projection_get)
|
||||
batch = plan_identifier_migration_batch(
|
||||
plan,
|
||||
repo_slugs=["one"],
|
||||
projection_api_bases=["http://hub.test"],
|
||||
)
|
||||
|
||||
assert batch["ok"] is False
|
||||
assert batch["ready_for_approval"] is False
|
||||
assert batch["repositories"][0]["projection_preflight"]["ok"] is False
|
||||
assert any("current UUID=200" in error["reason"] for error in batch["errors"])
|
||||
|
||||
|
||||
def test_migration_batch_plan_rejects_dirty_or_duplicate_scope(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "one"
|
||||
|
|
|
|||
|
|
@ -294,6 +294,54 @@ def test_repairs_an_already_identified_workplan_projection(tmp_path: Path, monke
|
|||
assert brief.read_text(encoding="utf-8") == "authoritative local brief\n"
|
||||
|
||||
|
||||
def test_repair_fails_when_exact_projection_remains_absent(tmp_path: Path, monkeypatch) -> None:
|
||||
repo = _fixture(tmp_path)
|
||||
workplan = repo / "workplans" / "DEMO-WP-0001.md"
|
||||
text = workplan.read_text(encoding="utf-8")
|
||||
text = text.replace(
|
||||
"status: active\n---",
|
||||
'status: active\nstate_hub_workstream_id: "11111111-1111-4111-8111-111111111111"\n---',
|
||||
)
|
||||
text = text.replace(
|
||||
"priority: high\n```",
|
||||
'priority: high\nstate_hub_task_id: "22222222-2222-4222-8222-222222222222"\n```',
|
||||
)
|
||||
workplan.write_text(text, encoding="utf-8")
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-m", "add authoritative identifiers")
|
||||
_git(repo, "push")
|
||||
|
||||
monkeypatch.setattr(
|
||||
rr,
|
||||
"_check_primary",
|
||||
lambda _api: ({"status": "ok", "db": "connected"}, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rr,
|
||||
"_projection_exists",
|
||||
lambda _api, _projection_id: ({}, "workplan projection returned 404"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rr,
|
||||
"_run_statehub",
|
||||
lambda command, *, env: subprocess.CompletedProcess(
|
||||
command, 1, "stale reference remains", ""
|
||||
),
|
||||
)
|
||||
|
||||
result = rr.registrar_reconcile(
|
||||
repo,
|
||||
statehub_bin="statehub",
|
||||
confirm_primary=True,
|
||||
repair_workplan="DEMO-WP-0001",
|
||||
)
|
||||
|
||||
assert result.status == "failed"
|
||||
assert result.error and result.error["code"] == "registration_incomplete"
|
||||
assert result.evidence["repair_projection_verified"] is False
|
||||
assert "requested_projection_verified" not in result.evidence
|
||||
|
||||
|
||||
def test_bootstraps_and_verifies_a_completely_empty_projection(tmp_path: Path, monkeypatch) -> None:
|
||||
repo = _fixture(tmp_path)
|
||||
workplan = repo / "workplans" / "DEMO-WP-0001.md"
|
||||
|
|
|
|||
|
|
@ -541,6 +541,27 @@ note are in
|
|||
`docs/evidence/RMGR-WP-0005-batch-0004-railiance-cluster-cutover-2026-08-22.md`.
|
||||
T04 remains in progress for separately sealed and approved fleet batches.
|
||||
|
||||
**Batch 0005 preflight blocked safely (2026-08-22):** the refreshed zero-collision
|
||||
fleet plan covers 39 repositories and 215 live records. A projection-aware
|
||||
`adaptive-pricing` batch pinned its clean synchronized source and five UUID
|
||||
replacements, then proved the workstation has all five old rows while production
|
||||
has none. It therefore records `ready_for_approval: false` and authorizes no
|
||||
mutation. A governed repair attempt retained State Hub's conservative C-03
|
||||
refusal for absent random pre-derivation UUIDs.
|
||||
|
||||
That attempt exposed and fixed a Repo Manager fail-open result classification:
|
||||
an empty ordinary-registration set could previously make an unverified exact
|
||||
repair report `applied`. Repair and bootstrap modes now require their own exact
|
||||
projection proof. Batch planning can also pin repeated
|
||||
`--projection-api-base` endpoints and fails readiness unless every replacement
|
||||
is current=200/derived=404 on every hub; verification repeats those live checks.
|
||||
|
||||
The remaining cross-owner gap is directly adoptable as
|
||||
`helixforge.identifiers.state-hub-sealed-projection-repair.v1`. It asks State Hub
|
||||
for a registrar-only, sealed, atomic restoration path without weakening normal
|
||||
random stale-reference refusal. Evidence:
|
||||
`docs/evidence/RMGR-WP-0005-batch-0005-adaptive-pricing-preflight-2026-08-22.md`.
|
||||
|
||||
## Retire the interim rule
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue