fix(wake): #925 framework-ship canon fallback-wake (systemd timer + schema bound + A10 install/validate) — F7 out of the box
Some checks failed
ci/woodpecker/pr/ci Pipeline failed

Framework-ship the canon-side FALLBACK WAKE (F7 replacement-before-retirement)
so hosts get it out of the box instead of hand-wiring it per host. ADDITIVE;
honors #869 / Gate A/B discipline.

1. New framework units systemd/user/mosaic-wake-fallback.{timer,service}: a
   low-frequency SAFETY drain (oneshot service running the canon drain
   `digest.sh render --from-store`) fired by a per-class cadence timer,
   INDEPENDENT of the event-driven detector, so a stalled detector/daemon can
   never silently starve delivery. Owned via the existing `systemd/**` glob in
   framework-manifest.txt (Gate A) — no new owned path, no second authority.
2. Optional additive per-class `fallback_cadence` bound in
   wake-watch-list.schema.json (config, not code). Backward-compatible within
   schema_version 1, so [schema_min, schema_max] stays [1,1] and the detector's
   Gate B range check is unchanged.
3. A10 wake-install.sh enumerates + links + validates the two units into the
   user systemd search path (idempotent, fail-closed); write-fallback-cadence
   writes the per-class cadence as a blank-reset drop-in (exactly one
   effective OnUnitActiveUSec).
4. F7 install-validate: the §5 legacy reap now REFUSES unless the canon
   fallback wake is proven live (installed + schedulable floor always; enabled
   + proven-firing when a live user manager is probeable, mirroring #913).

Red-first: test-wake-install.sh I11 (F7 reap-refuses-without-live-fallback),
I12 (stalled detector still drains), I13 (install links+validates units),
I14 (per-class cadence blank-reset = exactly one); test-wake-detector.sh D9
(optional fallback_cadence in-range accepted; out-of-range still fails loud).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158NZqN2n2ymKFeJAZ4GUCb
This commit is contained in:
mosaic-coder
2026-07-26 08:07:03 -05:00
parent 0ea41e848b
commit 8de52b7b59
7 changed files with 485 additions and 2 deletions

View File

@@ -193,13 +193,19 @@ echo "== I6: reset->verify->retire — overlap keeps legacy running; only §4-ve
export WAKE_SNAPSHOT_DIR="$SNAP"
TIMER="mosaic-heartbeat@c.timer"
printf '[Timer]\nOnUnitActiveSec=15min\n' >"$UD/$TIMER"
# F7 (#925): the vector-passed reap now REFUSES unless the canon FALLBACK WAKE is
# proven live (replacement-before-retirement). Provision it at the schedulable
# floor so the lifecycle path under test still reaches the reap. (I11 is the
# dedicated F7 refuse/allow control.)
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.timer" "$UD/mosaic-wake-fallback.timer"
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service" "$UD/mosaic-wake-fallback.service"
# (a) overlap phase (NO --vector-passed): reset+verify, legacy LEFT RUNNING.
out="$(bash "$WI" reset-verify-retire c --interval 30min 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "I6: overlap reset-verify must succeed (rc=$rc) [$out]"
[ -f "$UD/$TIMER" ] || fail_msg "I6: overlap phase must LEAVE the legacy timer running (retire withheld)"
[ -f "$SNAP/$TIMER" ] || fail_msg "I6: the legacy timer must be snapshotted before any retire"
echo "$out" | grep -qi 'LEFT RUNNING' || fail_msg "I6: overlap must announce retire is withheld [$out]"
# (b) §4-vector pass: retire LAST, snapshot-guarded.
# (b) §4-vector pass: retire LAST, snapshot-guarded (fallback proven live above).
out2="$(bash "$WI" reset-verify-retire c --interval 30min --vector-passed 2>&1)"; rc2=$?
[ "$rc2" -eq 0 ] || fail_msg "I6: vector-passed retire must succeed (rc=$rc2) [$out2]"
[ ! -f "$UD/$TIMER" ] || fail_msg "I6: on §4-vector pass the legacy timer must be RETIRED"
@@ -315,6 +321,107 @@ echo "== I10: systemd search-path — install links mosaic-wake.service into the
bash "$WI" validate-systemd-path >/dev/null 2>&1 || fail_msg "I10: re-install must keep the unit resolvable"
) && ok
# ── (#925) canon FALLBACK WAKE (F7 replacement-before-retirement) ──────────────
echo "== I11: F7 — the legacy reap REFUSES unless the canon fallback wake is proven live =="
(
UD="$(fresh i11-units)"; SNAP="$(fresh i11-snap)"
export WAKE_SYSTEMD_USER_DIR="$UD"
export WAKE_SNAPSHOT_DIR="$SNAP"
TIMER="mosaic-heartbeat@f.timer"
printf '[Timer]\nOnUnitActiveSec=15min\n' >"$UD/$TIMER"
# (a) fallback-proven-live is the F7 gate primitive: with NO fallback installed,
# it must REFUSE (schedulable floor not met).
out0="$(bash "$WI" fallback-proven-live 2>&1)"; rc0=$?
[ "$rc0" -ne 0 ] || fail_msg "I11: fallback-proven-live must FAIL when the fallback wake is not installed (rc=$rc0)"
echo "$out0" | grep -qi 'F7' || fail_msg "I11: the refusal must name the F7 precondition [$out0]"
# (b) NEGATIVE — vector-passed reap with NO live fallback must be REFUSED and the
# legacy timer LEFT RUNNING (never a coverage gap).
out="$(bash "$WI" reset-verify-retire f --interval 30min --vector-passed 2>&1)"; rc=$?
[ "$rc" -ne 0 ] || fail_msg "I11: reap must be REFUSED without a proven-live fallback (rc=$rc)"
echo "$out" | grep -qi 'FALLBACK WAKE is not proven live' || fail_msg "I11: refusal must name the un-proven fallback [$out]"
[ -f "$UD/$TIMER" ] || fail_msg "I11: a refused reap must LEAVE the legacy timer running (F7 — no coverage gap)"
# (c) POSITIVE — provision the fallback at the schedulable floor, then the reap
# proceeds (F7 satisfied) and the legacy timer is retired.
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.timer" "$UD/mosaic-wake-fallback.timer"
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service" "$UD/mosaic-wake-fallback.service"
bash "$WI" fallback-proven-live >/dev/null 2>&1 || fail_msg "I11: fallback-proven-live must PASS once the fallback units are schedulable"
out2="$(bash "$WI" reset-verify-retire f --interval 30min --vector-passed 2>&1)"; rc2=$?
[ "$rc2" -eq 0 ] || fail_msg "I11: with a proven-live fallback the reap must succeed (rc=$rc2) [$out2]"
[ ! -f "$UD/$TIMER" ] || fail_msg "I11: with F7 satisfied the legacy timer must be RETIRED"
) && ok
echo "== I12: fallback drain — a STALLED detector still gets delivery via the canon fallback drain =="
(
H="$(fresh i12-state)"
export WAKE_STATE_HOME="$H"
unset WAKE_AGENT
STORE="$SCRIPT_DIR/store.sh"; DIGEST="$SCRIPT_DIR/digest.sh"
# A pending obligation lands in the durable inbox — but the detector daemon is
# STALLED/absent (we never run detector.sh). Enqueue directly to the store.
seq="$(bash "$STORE" enqueue --class digest \
--locators '{"kind":"repo","id":"STALLED-DRAIN-MARK","path":"BOARD.md","observed_hash":"deadbeef"}' 2>/dev/null)"
[ -n "$seq" ] || fail_msg "I12: store enqueue must allocate a seq"
# The canon fallback drain is EXACTLY the shipped unit's ExecStart: digest render
# --from-store. Firing it (as the timer would) must surface the pending obligation
# even with no detector running — no starvation of the drain.
out="$(bash "$DIGEST" render --from-store 2>/dev/null)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "I12: the canon fallback drain must exit 0 (rc=$rc)"
echo "$out" | grep -q 'STALLED-DRAIN-MARK' \
|| fail_msg "I12: the fallback drain must DELIVER the pending obligation a stalled detector left (no delivery starvation) [$out]"
# Bind the invariant to the SHIPPED unit: its ExecStart must be this canon drain.
UNIT="$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service"
grep -qE '^ExecStart=.*digest\.sh render --from-store' "$UNIT" \
|| fail_msg "I12: mosaic-wake-fallback.service ExecStart must fire the canon drain (digest.sh render --from-store)"
) && ok
echo "== I13: install links + validates the canon fallback timer+service into the search path (idempotent) =="
(
TGT="$(fresh i13-target)"
UD="$(fresh i13-systemd)"
export WAKE_INSTALL_SOURCE="$FRAMEWORK_ROOT"
export WAKE_INSTALL_TARGET="$TGT"
export WAKE_SYSTEMD_USER_DIR="$UD"
bash "$WI" install >/dev/null 2>&1 || fail_msg "I13: install must succeed"
# (a) both fallback units resolve in the user systemd search path.
[ -e "$UD/mosaic-wake-fallback.timer" ] || fail_msg "I13: fallback timer must be linked into the search path"
[ -e "$UD/mosaic-wake-fallback.service" ] || fail_msg "I13: fallback service must be linked into the search path"
bash "$WI" validate-fallback-units >/dev/null 2>&1 || fail_msg "I13: validate-fallback-units must pass after install"
# (b) NEGATIVE CONTROL: a missing fallback unit is CAUGHT (fail loud, names the miss).
rm -f "$UD/mosaic-wake-fallback.timer"
out="$(bash "$WI" validate-fallback-units 2>&1)"; rc=$?
[ "$rc" -ne 0 ] || fail_msg "I13: validate must FAIL when a fallback unit is not in the search path (rc=$rc) [$out]"
echo "$out" | grep -qi 'search path' || fail_msg "I13: validate failure must name the search-path miss [$out]"
# (c) idempotent re-install: exactly one entry per unit, still resolvable.
bash "$WI" install >/dev/null 2>&1 || fail_msg "I13: re-run install must succeed"
nt="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.timer' | grep -c .)"
ns="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.service' | grep -c .)"
[ "$nt" -eq 1 ] && [ "$ns" -eq 1 ] || fail_msg "I13: re-install must not duplicate the fallback entries (timer=$nt service=$ns)"
bash "$WI" validate-fallback-units >/dev/null 2>&1 || fail_msg "I13: re-install must keep the fallback units resolvable"
) && ok
echo "== I14: per-class fallback cadence — blank-reset yields EXACTLY ONE OnUnitActiveUSec (negative control leaks two) =="
(
UD="$(fresh i14-units)"
export WAKE_SYSTEMD_USER_DIR="$UD"
TIMER="mosaic-wake-fallback.timer"
# The shipped timer carries the base OnUnitActiveSec placeholder (=1h).
cp "$FRAMEWORK_ROOT/systemd/user/$TIMER" "$UD/$TIMER"
grep -q '^OnUnitActiveSec=1h' "$UD/$TIMER" || fail_msg "I14: shipped fallback timer must carry a base OnUnitActiveSec placeholder"
# write-fallback-cadence writes the per-class cadence in BLANK-RESET form.
out="$(bash "$WI" write-fallback-cadence 30min 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "I14: write-fallback-cadence must succeed + verify single (rc=$rc) [$out]"
echo "$out" | grep -qi 'EXACTLY ONE\|exactly one' || fail_msg "I14: the cadence write must confirm exactly-one OnUnitActiveUSec [$out]"
[ -f "$UD/$TIMER.d/cadence.conf" ] || fail_msg "I14: the per-class cadence drop-in must be written under <timer>.d/"
grep -qz $'OnUnitActiveSec=\nOnUnitActiveSec=30min' "$UD/$TIMER.d/cadence.conf" \
|| fail_msg "I14: the drop-in must use the blank-reset form (empty reset line, then the value)"
bash "$WI" verify-single "$TIMER" >/dev/null 2>&1 || fail_msg "I14: base(1h)+blank-reset(30min) must resolve exactly one OnUnitActiveUSec"
# NEGATIVE CONTROL: a drop-in WITHOUT the reset line appends -> base 1h + 30min -> two -> fail.
printf '[Timer]\nOnUnitActiveSec=30min\n' >"$UD/$TIMER.d/cadence.conf"
out2="$(bash "$WI" verify-single "$TIMER" 2>&1)"; rc2=$?
[ "$rc2" -ne 0 ] || fail_msg "I14: without the reset line two cadences must leak (verify should FAIL) [$out2]"
) && ok
echo
if [ -s "$FAILFILE" ]; then
echo "wake install harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2