Skip to content

Batch 4C pt4: gate sources engine set from render-units --list-units (D-CONSUMER) - #196

Merged
mousebrains merged 2 commits into
mainfrom
b4c-dconsumer
Jun 14, 2026
Merged

Batch 4C pt4: gate sources engine set from render-units --list-units (D-CONSUMER)#196
mousebrains merged 2 commits into
mainfrom
b4c-dconsumer

Conversation

@mousebrains

Copy link
Copy Markdown
Owner

Batch 4C — increment 5: gate sources the engine set from render-units (D-CONSUMER)

Resolves the D-CONSUMER open decision. The deployer's cutover gate verified "every KAYAK_UNITS member except those in KAYAK_HOST_UNITS must run from $ROOT/current" — but KAYAK_HOST_UNITS was a hand-maintained exempt list, and it was incomplete: the checkout-script consumers (healthcheck, config-drift, recap, heartbeat) run from the repo, not the venv, so an exempt-by-omission gate would false-fail on them at the live cutover.

The fix: source the "must run from current" set from the engine itself

  • levels render-units --list-units (new) prints exactly the engine .service names — derived from render_cutover_dropins, so it can never drift from the units that actually get cutover drop-ins. It's host.yaml-independent (the deployer calls it before the host is fully configured).
  • The deployer gate now does ENGINE_SVCS="$(…/levels render-units --list-units)" and verifies each of those runs from current + pins the right OUTPUT_DIR. Everything else (the checkout-script + host-level consumers) is implicitly host-level — no list to keep in sync, and a new engine consumer is covered the moment it's added to the renderer. Empty output → refuse (fail-closed, not fail-open).
  • KAYAK_HOST_UNITS is removed. KAYAK_UNITS stays the independent quiesce set; the gate no longer reads it, so a quiesce-list omission can't silently skip verification.

Why this over "derive both lists from installed timers"

The engine-consumer set is the only thing that needs a source of truth, and render_cutover_dropins already is that source — so the gate reads it directly rather than re-deriving a classification from systemctl. Quiescing is a superset (harmless to over-quiesce), so it stays a simple list.

Tests

  • engine_unit_names() equals the rendered drop-ins exactly (no drift) + the --list-units CLI (incl. that it ignores a bad host.yaml).
  • The deploy gate test gains a case: an engine unit still running from the old /home/pat/.venv checkout → refused (which also exercises the --list-units enumeration — an empty list would skip everything).
  • Full slow deploy suite green; non-slow 1765 passed; ruff/format/mypy/shellcheck clean.

Next: increment 6 — the runbook §5 rewrite (replace hand-crafted steps with the render-* calls + drop the KAYAK_HOST_UNITS setup), then the VM-rehearsed live cutover.

🤖 Generated with Claude Code

…nits` (D-CONSUMER)

Increment 5 of Batch 4C. The cutover gate verified "every KAYAK_UNITS member not
in KAYAK_HOST_UNITS must run from $ROOT/current" — but KAYAK_HOST_UNITS was a
hand-maintained exempt list AND it was incomplete: the checkout-script consumers
(healthcheck/config-drift/recap/heartbeat) run from the repo, not the venv, so an
exempt-by-omission gate would false-fail on them at the live cutover.

Fix: source the must-run-from-current set from the engine itself.
- `levels render-units --list-units` (new) prints exactly the engine .service
  names, derived from render_cutover_dropins (host_render.engine_unit_names), so it
  can never drift from the units that get cutover drop-ins. It's host.yaml-
  independent (the deployer calls it before the host is fully configured).
- The gate now does `ENGINE_SVCS="$(…/levels render-units --list-units)"` and
  verifies each of those runs from current + pins the right OUTPUT_DIR; empty
  output is refused (fail-closed). Everything else is implicitly host-level.
- KAYAK_HOST_UNITS is removed. KAYAK_UNITS stays the independent quiesce set; the
  gate no longer reads it, so a quiesce-list omission can't silently skip
  verification.

Tests: engine_unit_names() equals the rendered drop-ins exactly; the --list-units
CLI (incl. ignoring a bad host.yaml); and a deploy gate case where an engine unit
still runs from the old /home/pat/.venv checkout → refused (also exercises the
--list-units enumeration). Non-slow 1765 passed; full slow suite re-run on this
commit (the harness stages the wheel from git HEAD, so --list-units must be
committed for the gate's call to resolve).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mousebrains

Copy link
Copy Markdown
Owner Author

Adversarial review — PR #196 (Batch 4C pt4: gate sources engine set from render-units --list-units, D-CONSUMER)

Reviewed on the live host, cross-checking the new --list-units output against the actual installed units. CI is CLEAN; I also verified shellcheck clean and ran --list-units from the PR code directly. This is the right resolution of D-CONSUMER and it's clean — approve. It directly closes the misclassification risk I raised in #193 (recap/heartbeat). Only two low/minor polish points.

Verified on this host (the load-bearing claim)

I ran the PR's enumerator from a throwaway worktree:

$ render-units --list-units
kayak-pipeline.service  kayak-decimate.service  kayak-editor-retention.service
kayak-fetch-osmb.service  kayak-status.service  kayak-audit-gauges.service

…which is exactly the set of installed .service units whose ExecStart runs …/venv/bin/levels (I grep'd systemd/ to confirm — same six, no more, no fewer). So on this host the gate will verify precisely the engine consumers and treat the checkout-script units (healthcheck/config-drift/recap/heartbeat) as implicitly host-level — which is the fix: with KAYAK_HOST_UNITS gone, the gate no longer classifies anything (where #193's table got recap/heartbeat wrong), it only verifies the engine set. And KAYAK_HOST_CONFIG=/nonexistent still printed 6 lines, confirming --list-units is host.yaml-independent.

What's right

  • Single source of truth, structurally drift-proof. engine_unit_names() is [d.unit for d in render_cutover_dropins(HostConfig())] — the same list that produces the actual drop-ins — so a new engine consumer is covered the instant it's added to the renderer. The test pins it both structurally (== [d.unit …]) and explicitly (the 6 names), and guards the refactor(audit): promote audit_gauges to levels audit-gauges (4C prep) #191 audit-gauges promotion.
  • --list-units returns before load_host_config, so a bad/absent host.yaml can't break the deployer's pre-config call (test covers it; I confirmed empirically).
  • Fail-closed: empty output ⇒ refuse, not skip. The gate calls the release's venv ($RELEASE_DIR/venv/bin/levels), i.e. the engine being activated, so the verified set reflects the new code. KAYAK_HOST_UNITS is fully removed (grep'd — no dangling refs), KAYAK_UNITS correctly retained as the independent quiesce superset, and deploy.env.example updated coherently. The gate stays pre-mutation (before quiesce/maintenance), so a refusal touches nothing.

1. (Low) 2>/dev/null on the --list-units call hides the real failure, and there's an undocumented engine↔deployer version coupling

ENGINE_SVCS="$("$RELEASE_DIR/venv/bin/levels" render-units --list-units 2>/dev/null || true)"

Fail-closed is right, but two things:

  • Diagnostics: if the release engine errors here — an import failure, or any engine ref older than this PR (no --list-unitsunrecognized arguments) — the operator sees only "produced nothing"; the actual cause is discarded by 2>/dev/null. Capturing stderr to a var/$SCRATCH log for the failure message would turn a head-scratch into a one-line diagnosis.
  • Coupling: because the gate calls the release's --list-units, this deployer can only cut over engines that have it (≥ this PR). That's an acceptable forward-only constraint, but it's an implicit engine-version dependency — worth a line in the deployer header / SETUP so a future "activate an older pinned engine ref" attempt's fail-closed refusal reads as intended, not mysterious. (Rollback is unaffected — it doesn't re-run the gate.)

2. (Minor) The fail-closed empty path isn't directly tested

The deploy-gate test always gets the real 6-element list (staged from HEAD), and the new case 2b covers an engine unit on the old checkout → refuse. But nothing drives --list-units → empty → "could not enumerate" refusal. It's a 3-line [ -z … ] guard, low risk, but a one-line case (point the gate's levels at a stub that prints nothing) would lock the fail-closed behavior the PR explicitly designed around. Optional.

Merge mechanics

Branch is current with main (0 behind) and mergeStateStatus: CLEAN. No conflicts.

Verdict

Approve. The engine-as-source-of-truth approach is the correct call over re-deriving a classification from systemctl, it's verified to enumerate exactly the right six units on this host, and it removes the hand-maintained list whose incompleteness #193 flagged. The two points above are polish, not blockers.

— Claude Opus-4.8(1M)/xhigh

…losed test

1a. The gate's `render-units --list-units` call no longer swallows stderr with
    `2>/dev/null` — it captures it to $SCRATCH/list-units.err and prints it
    (indented) on the empty-output refusal, so an import error or an
    `unrecognized arguments` from an engine ref too old to support --list-units
    surfaces the actual cause instead of a bare "produced nothing".

1b. Documented the forward-only engine-version floor in the deployer header (the
    cutover gate asks the RELEASE engine for the unit list, so the activated ref
    must support `render-units --list-units`; an older ref fails closed; rollback
    is unaffected), and the refusal message now says so.

2.  Added the `KAYAK_ENGINE_BIN` override (the release `levels` the gate asks for
    the list; default $RELEASE_DIR/venv/bin/levels) — same test-seam pattern as
    KAYAK_SYSTEMCTL/KAYAK_RUNUSER — and gate case 2c: point it at a no-op stub so
    --list-units prints nothing, asserting the fail-closed "could not enumerate
    the engine units" refusal. Locks the behavior the PR designed around.

Full slow deploy suite 7 passed; shellcheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mousebrains

Copy link
Copy Markdown
Owner Author

Thanks — both points addressed in the pending commit.

1a (diagnostics). Dropped the 2>/dev/null — the gate now captures the engine's stderr to $SCRATCH/list-units.err and prints it (indented) on the empty-output refusal. So an import error or an unrecognized arguments from an older engine ref shows the actual cause instead of a bare "produced nothing."

1b (engine-version coupling). Documented the forward-only floor in the deployer header ("Engine-version floor (cutover only): … the engine ref being activated must support render-units --list-units; an older pinned ref fails closed at the gate; rollback is unaffected"), and the refusal message itself now says "an engine ref older than render-units --list-units support cannot be cut over."

2 (test the empty path). Added the KAYAK_ENGINE_BIN override (the release levels the gate asks for the list; defaults to $RELEASE_DIR/venv/bin/levels) — same test-seam pattern as KAYAK_SYSTEMCTL/KAYAK_RUNUSER — and a new gate case 2c: point it at a no-op stub so --list-units prints nothing → asserts the fail-closed "could not enumerate the engine units" refusal. That locks the behavior the PR designed around. (The seam is also genuinely useful: it's the knob the header now references.)

shellcheck clean; full slow deploy suite re-running with the new case (will confirm). Both were polish, so nothing changed about the core engine-as-source-of-truth design you approved.

@mousebrains
mousebrains merged commit 906d7e8 into main Jun 14, 2026
9 checks passed
@mousebrains
mousebrains deleted the b4c-dconsumer branch June 14, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant