fix(registrar): make --confirm-primary assert authority, not liveness

_check_primary accepted any instance reporting status=ok and db=connected.
A local cache and the central hub both satisfied that for seven weeks while
every registration went to the cache — a liveness check wearing an authority
check's name.

It now requires the hub to declare instance_role=primary. An instance that
declares nothing is refused with a message naming what to set; proceeding
anyway requires an explicit --allow-unverified-primary rather than a silent
default.

Four tests cover the logic directly; the existing suite stubbed _check_primary
and never exercised it.

Refs CUST-WP-0067-T03

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
tegwick 2026-08-25 10:32:51 +02:00
parent 68943631d8
commit 10bdb683cb
3 changed files with 105 additions and 11 deletions

View file

@ -64,6 +64,12 @@ def main(argv: list[str] | None = None) -> int:
help="Confirm that --api-base is the authoritative hub",
)
p_registrar.add_argument("--push", action="store_true", help="Push the registrar commit")
p_registrar.add_argument(
"--allow-unverified-primary",
action="store_true",
help="Proceed when the hub declares no instance role. Explicit on purpose: "
"the alternative is silently trusting whatever answered.",
)
registrar_mode = p_registrar.add_mutually_exclusive_group()
registrar_mode.add_argument(
"--repair-workplan",
@ -517,6 +523,7 @@ def main(argv: list[str] | None = None) -> int:
api_base=args.api_base,
statehub_bin=args.statehub_bin,
confirm_primary=args.confirm_primary,
allow_unverified_primary=args.allow_unverified_primary,
push=args.push,
repair_workplan=args.repair_workplan,
bootstrap_empty_projection=args.bootstrap_empty_projection,

View file

@ -176,7 +176,16 @@ def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]:
}, None
def _check_primary(api_base: str) -> tuple[dict[str, Any], str | None]:
def _check_primary(
api_base: str, *, allow_unverified: bool = False
) -> tuple[dict[str, Any], str | None]:
"""Verify the target is the authoritative hub, not merely a healthy one.
This used to assert only status/db, which every instance satisfies a
liveness check wearing an authority check's name. A local cache and central
both passed it for seven weeks while onboarding went to the cache
(CUST-WP-0067-T03, ADR-010).
"""
try:
response = httpx.get(f"{api_base.rstrip('/')}/state/health", timeout=10.0)
response.raise_for_status()
@ -185,7 +194,24 @@ def _check_primary(api_base: str) -> tuple[dict[str, Any], str | None]:
return {}, f"primary State Hub health check failed: {exc}"
if payload.get("status") != "ok" or payload.get("db") != "connected":
return payload, "State Hub is not healthy and database-connected"
return payload, None
role = payload.get("instance_role")
label = payload.get("instance_label") or api_base
if role == "primary":
return payload, None
if role in (None, "unknown"):
if allow_unverified:
return payload, None
return payload, (
f"{api_base} does not declare an instance role, so it cannot be "
"confirmed as the authoritative hub. Set STATE_HUB_INSTANCE_ROLE="
"primary on the central deployment. To proceed without that "
"guarantee, pass --allow-unverified-primary explicitly."
)
return payload, (
f"{api_base} reports instance_role={role!r} ({label}); refusing to treat "
"it as the authoritative hub"
)
def _workplan_projection_id(repo: Path, canonical_id: str) -> str | None:
@ -410,6 +436,7 @@ def registrar_reconcile(
api_base: str = "http://127.0.0.1:8000",
statehub_bin: str | None = None,
confirm_primary: bool = False,
allow_unverified_primary: bool = False,
push: bool = False,
repair_workplan: str | None = None,
bootstrap_empty_projection: bool = False,
@ -502,7 +529,9 @@ def registrar_reconcile(
"rejected", evidence, {"code": "git_precondition_failed", "message": git_error}, cid
)
health, health_error = _check_primary(api_base)
health, health_error = _check_primary(
api_base, allow_unverified=allow_unverified_primary
)
evidence["state_hub_health"] = health
if health_error:
return RegistrarResult(