fix(wake): #925 framework-ship canon fallback-wake (systemd timer + schema bound + A10 install/validate) — F7 out of the box (#931)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: jason.woltje <jason@diversecanvas.com>
Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #931.
This commit is contained in:
2026-07-26 13:44:51 +00:00
committed by Mos
parent 0ea41e848b
commit 17087efe15
7 changed files with 489 additions and 2 deletions

View File

@@ -51,6 +51,13 @@ WAKE_SNAPSHOT_DIR="${WAKE_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mo
# systemd user unit NAME this installer deploys + links (overridable for the harness).
WAKE_UNIT_NAME="${WAKE_UNIT_NAME:-mosaic-wake.service}"
# The framework-shipped canon FALLBACK WAKE units (F7, #925): a low-frequency
# SAFETY drain (oneshot service) driven by a per-class cadence timer, INDEPENDENT
# of the event-driven detector. Both are framework-owned via the existing
# `systemd/**` glob in framework-manifest.txt (Gate A) — NO new owned path, NO
# second ownership authority (#869 additive). Overridable for the harness.
WAKE_FALLBACK_TIMER_NAME="${WAKE_FALLBACK_TIMER_NAME:-mosaic-wake-fallback.timer}"
WAKE_FALLBACK_SERVICE_NAME="${WAKE_FALLBACK_SERVICE_NAME:-mosaic-wake-fallback.service}"
# The shared framework-manifest reader this installer needs for Gate A ownership
# validation. Overridable so the red-first harness can point it at a missing path
# to prove the fail-loud without disturbing the real tree (#913a).
@@ -108,6 +115,11 @@ _wi_component_candidates() {
# shared framework subtrees. Listed here for ENUMERATION only — ownership is
# still decided solely by framework-manifest.txt in wi_install.
printf '%s\n' "systemd/user/mosaic-wake.service"
# The canon FALLBACK WAKE units (F7, #925). Both resolve framework-owned via the
# existing `systemd/**` glob — enumerated here so wi_install COPIES them; still
# Gate-A-validated against the framework-manifest SSOT before any byte is written.
printf '%s\n' "systemd/user/$WAKE_FALLBACK_TIMER_NAME"
printf '%s\n' "systemd/user/$WAKE_FALLBACK_SERVICE_NAME"
printf '%s\n' "defaults/wake-watch-list.schema.json"
}
@@ -164,6 +176,14 @@ wi_install() {
wi_link_systemd_unit || return 1
wi_validate_systemd_path || return 1
# (#925) A10 canon step: place the canon FALLBACK WAKE units (timer + oneshot
# service) into the user systemd search path and validate they resolve + parse.
# Same link-back-to-SSOT, idempotent, fail-closed pattern as the detector unit
# above. The per-class cadence drop-in (blank-reset) and the F7 proven-live gate
# are separate steps (write-fallback-cadence / reset-verify-retire).
wi_link_fallback_units || return 1
wi_validate_fallback_units || return 1
# Machine-readable summary for the harness (idempotency assertion keys off it).
printf 'wake-install: written=%s skipped=%s missing=%s\n' "$written" "$skipped" "$missing"
}
@@ -255,6 +275,183 @@ wi_validate_systemd_path() {
return 0
}
# ═══════════════════════════════════════════════════════════════════════════════
# (#925) canon FALLBACK WAKE units — link + validate + per-class cadence + F7 gate
# ═══════════════════════════════════════════════════════════════════════════════
# ADDITIVE / #869: exactly as for the detector unit, the link target lives OUTSIDE
# mosaic home, so it is NOT a framework-manifest path. Ownership of the SSOT units
# stays the single `systemd/**` glob in framework-manifest.txt — NO new owned path,
# NO second ownership authority. The link points BACK to the mosaic-home SSOT copy.
# _wi_link_one UNIT — idempotently place ONE deployed unit into the user systemd
# search path (symlink to the mosaic-home SSOT copy). Shared idiom with the
# detector's wi_link_systemd_unit; an already-correct link / byte-identical file is
# left untouched, a stale entry is replaced. Fail-closed on a missing SSOT copy.
_wi_link_one() {
local unit="$1"
local ssot="$WAKE_INSTALL_TARGET/systemd/user/$unit"
local link="$WAKE_SYSTEMD_USER_DIR/$unit"
if [[ ! -f "$ssot" ]]; then
wi_fail "systemd-link — the deployed unit is missing at $ssot; run 'wake-install.sh install' first (fail-closed)."
return 1
fi
mkdir -p "$WAKE_SYSTEMD_USER_DIR"
if [[ -L "$link" ]]; then
if [[ "$(readlink "$link")" == "$ssot" ]]; then
wi_ok "systemd-link — '$unit' already linked into the search path ($link -> $ssot)."
return 0
fi
elif [[ -f "$link" ]] && cmp -s "$ssot" "$link"; then
wi_ok "systemd-link — '$unit' already present in the search path ($link, byte-identical)."
return 0
fi
if ! ln -sfn "$ssot" "$link"; then
wi_fail "systemd-link — could not link '$unit' into the user systemd search path ($link). FAIL LOUD (fail-closed)."
return 1
fi
wi_ok "systemd-link — '$unit' linked into the user systemd search path ($link -> $ssot)."
return 0
}
# wi_link_fallback_units — link BOTH canon fallback units (timer + oneshot service)
# into the user systemd search path so `systemctl --user` can resolve+enable them.
wi_link_fallback_units() {
_wi_link_one "$WAKE_FALLBACK_TIMER_NAME" || return 1
_wi_link_one "$WAKE_FALLBACK_SERVICE_NAME" || return 1
return 0
}
# _wi_validate_one UNIT KIND — post-install validate that ONE unit resolves in the
# user systemd search path AND is well-formed for its KIND. KIND=timer requires
# [Timer] + a base OnUnitActiveSec (the blank-reset target); KIND=service requires
# [Service] + ExecStart. As with the detector, the AUTHORITATIVE floor is
# path + well-formedness (the installer often runs with no live user manager); a
# live `systemctl --user cat` probe runs ONLY opportunistically behind the guard.
_wi_validate_one() {
local unit="$1" kind="$2"
local link="$WAKE_SYSTEMD_USER_DIR/$unit" resolved
if [[ ! -e "$link" ]]; then
wi_fail "fallback-validate — '$unit' is NOT in the user systemd search path ($link). systemctl --user cannot resolve it, so the canon fallback wake cannot be enabled/started. Run 'wake-install.sh link-fallback-units' (part of install) (fail-closed)."
return 1
fi
if [[ -L "$link" ]]; then
resolved="$(readlink -f "$link" 2>/dev/null || true)"
else
resolved="$link"
fi
if [[ -z "$resolved" || ! -r "$resolved" ]]; then
wi_fail "fallback-validate — '$unit' search-path entry ($link) does not dereference to a readable unit file. FAIL LOUD (fail-closed)."
return 1
fi
local -a miss=()
grep -q '^\[Unit\]' "$resolved" || miss+=("[Unit]")
case "$kind" in
timer)
grep -q '^\[Timer\]' "$resolved" || miss+=("[Timer]")
grep -q '^\[Install\]' "$resolved" || miss+=("[Install]")
grep -q '^OnUnitActiveSec=' "$resolved" || miss+=("OnUnitActiveSec=")
;;
service)
grep -q '^\[Service\]' "$resolved" || miss+=("[Service]")
grep -q '^ExecStart=' "$resolved" || miss+=("ExecStart=")
;;
*)
wi_fail "fallback-validate — internal: unknown unit kind '$kind'."; return 2 ;;
esac
if [[ ${#miss[@]} -ne 0 ]]; then
wi_fail "fallback-validate — '$unit' ($resolved) is malformed: missing ${miss[*]}. Refusing to certify an ill-formed unit (fail-closed)."
return 1
fi
if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then
if systemctl --user cat "$unit" >/dev/null 2>&1; then
wi_ok "fallback-validate — 'systemctl --user cat $unit' resolves (live user manager confirmed)."
else
wi_warn "fallback-validate — file is in the search path + well-formed, but 'systemctl --user cat $unit' did not resolve (no live user manager / not daemon-reloaded). Non-fatal: run 'systemctl --user daemon-reload' in a live session."
fi
fi
wi_ok "fallback-validate — '$unit' resolves in the user systemd search path ($link -> $resolved, well-formed $kind)."
return 0
}
# wi_validate_fallback_units — both canon fallback units resolve + are well-formed.
wi_validate_fallback_units() {
_wi_validate_one "$WAKE_FALLBACK_TIMER_NAME" timer || return 1
_wi_validate_one "$WAKE_FALLBACK_SERVICE_NAME" service || return 1
return 0
}
# wi_write_fallback_cadence CADENCE — write the per-class cadence for the fallback
# TIMER as a BLANK-RESET drop-in (mosaic-wake-fallback.timer.d/cadence.conf), then
# verify exactly ONE effective OnUnitActiveUSec. CADENCE is the operator's per-class
# `fallback_cadence` from the watch-list (config, not code). Same blank-reset idiom
# as the legacy-timer cadence: an empty OnUnitActiveSec= reset line CLEARS the base
# value, then the new value sets exactly one — so the base placeholder in the shipped
# timer can never accumulate into a second effective cadence.
wi_write_fallback_cadence() {
local cadence="${1:-}"
[[ -n "$cadence" ]] || { wi_fail "write-fallback-cadence: CADENCE (per-class fallback_cadence) required."; return 2; }
local timer="$WAKE_FALLBACK_TIMER_NAME"
wi_write_blank_reset_dropin "$WAKE_SYSTEMD_USER_DIR/$timer.d/cadence.conf" "$cadence" || return 1
wi_verify_single_active "$timer" || {
wi_fail "write-fallback-cadence — blank-reset verify failed for '$timer'; the fallback cadence did not collapse to exactly one OnUnitActiveUSec (fail-closed)."
return 1
}
wi_ok "write-fallback-cadence — '$timer' cadence set to $cadence (blank-reset, exactly one OnUnitActiveUSec)."
return 0
}
# wi_fallback_proven_live — the F7 PRECONDITION check (replacement-before-retirement,
# #925). The legacy reap MUST NOT proceed unless the canon fallback wake is live +
# proven-firing, so there is never a coverage gap. Layered, fail-closed:
# FLOOR (always, even with no live user manager): both fallback units resolve in
# the user systemd search path AND are well-formed (SCHEDULABLE/enabled floor) —
# i.e. the fallback is INSTALLED and CAN fire. A missing/ill-formed unit => REFUSE.
# LIVE (opportunistic, WAKE_VERIFY_USE_SYSTEMCTL=1 + a reachable user manager,
# mirroring #913): additionally require the TIMER be ENABLED and PROVEN-FIRING —
# LastTriggerUSec is set (it has fired at least once) OR NextElapse is armed
# (it is scheduled to fire). A dead/never-armed timer => REFUSE.
# Returns 0 iff the fallback is proven live to the strongest tier available.
wi_fallback_proven_live() {
local timer="$WAKE_FALLBACK_TIMER_NAME" service="$WAKE_FALLBACK_SERVICE_NAME"
# FLOOR — installed + well-formed (schedulable). Reuses the validate above.
if ! wi_validate_fallback_units >/dev/null 2>&1; then
wi_fail "F7 fallback-proven-live — the canon FALLBACK WAKE ('$timer' + '$service') is NOT installed/schedulable in the user systemd search path. Replacement-before-retirement (F7) FORBIDS reaping the legacy timer without a live fallback (fail-closed). Run 'wake-install.sh install' + 'write-fallback-cadence <fallback_cadence>' first."
return 1
fi
# LIVE — opportunistic proven-firing when a real user manager is reachable.
if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then
local enabled last next
enabled="$(systemctl --user is-enabled "$timer" 2>/dev/null || true)"
if [[ "$enabled" != "enabled" && "$enabled" != "static" ]]; then
wi_fail "F7 fallback-proven-live — '$timer' is not enabled (systemctl --user is-enabled => '${enabled:-unknown}'). A disabled fallback cannot fire; refusing the legacy reap (F7, fail-closed)."
return 1
fi
last="$(systemctl --user show "$timer" -p LastTriggerUSec --value 2>/dev/null || true)"
next="$(systemctl --user show "$timer" -p NextElapseUSecRealtime --value 2>/dev/null || true)"
# Proven-firing: it has already fired (LastTrigger set) OR it is armed to fire
# (NextElapse set). A timer that is neither is dead => refuse.
if _wi_usec_set "$last" || _wi_usec_set "$next"; then
wi_ok "F7 fallback-proven-live — '$timer' is enabled and proven-firing (LastTrigger='${last:-n/a}', NextElapse='${next:-n/a}'). Legacy reap is unblocked (F7 satisfied)."
return 0
fi
wi_fail "F7 fallback-proven-live — '$timer' is enabled but has NEITHER fired (LastTriggerUSec unset) NOR is armed (NextElapseUSecRealtime unset): it is not proven-firing. Refusing the legacy reap (F7, fail-closed) — start the timer and confirm it fires first."
return 1
fi
wi_ok "F7 fallback-proven-live — canon FALLBACK WAKE is installed + schedulable (no live user manager to probe firing; schedulable/enabled floor satisfied, mirroring #913). Legacy reap is unblocked at the schedulable floor."
return 0
}
# _wi_usec_set VALUE — rc 0 iff VALUE is a set systemd USec timestamp: non-empty,
# not the "unset" sentinels systemd prints for a never-fired / never-armed timer.
_wi_usec_set() {
local v="$1"
[[ -n "$v" ]] || return 1
case "$v" in
0|n/a|'-'|'') return 1 ;;
*) return 0 ;;
esac
}
# ═══════════════════════════════════════════════════════════════════════════════
# (v) Fail-closed alarm-target + HMAC-key install-validation (G1/G2a)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -512,6 +709,17 @@ wi_reset_verify_retire() {
# 3) RETIRE LAST — only on the §4-vector pass, and only via the snapshot-guarded
# reap. Without --vector-passed the legacy units are LEFT RUNNING (overlap).
if [[ "$vector_passed" -eq 1 ]]; then
# F7 PRECONDITION (#925) — replacement-before-retirement. The reap MUST NOT
# proceed unless the canon FALLBACK WAKE is proven live (installed + schedulable
# at the floor; enabled + proven-firing when a live user manager is probeable).
# This encodes F7 into the installer, not operator memory: there is never a
# window where the legacy net is reaped before its replacement is carrying load.
if [[ "${WAKE_REQUIRE_FALLBACK_LIVE:-1}" == "1" ]]; then
wi_fallback_proven_live || {
wi_fail "reset-verify-retire — REFUSING to reap the legacy '$agent' heartbeat: the canon FALLBACK WAKE is not proven live (F7 replacement-before-retirement, fail-closed). The legacy net stays UP until the fallback is carrying load."
return 1
}
fi
wi_reap_unit "$legacy_timer" || return 1
# The paired service may not be independently deployed; reap it best-effort
# but still snapshot-guarded if present.
@@ -534,6 +742,10 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
install) wi_install "$@" ;;
link-systemd-unit) wi_link_systemd_unit "$@" ;;
validate-systemd-path) wi_validate_systemd_path "$@" ;;
link-fallback-units) wi_link_fallback_units "$@" ;;
validate-fallback-units) wi_validate_fallback_units "$@" ;;
write-fallback-cadence) wi_write_fallback_cadence "$@" ;;
fallback-proven-live) wi_fallback_proven_live "$@" ;;
validate-targets) wi_validate_targets "$@" ;;
validate-hmac-key) wi_validate_hmac_key "$@" ;;
validate-alarm-target) wi_validate_alarm_target "$@" ;;
@@ -552,6 +764,10 @@ Commands:
path and validate it resolves (b, #913).
link-systemd-unit Link mosaic-wake.service into ~/.config/systemd/user/.
validate-systemd-path Validate the unit resolves in the user systemd search path.
link-fallback-units Link the canon fallback wake timer+service into the search path (#925).
validate-fallback-units Validate the fallback timer+service resolve + are well-formed (#925).
write-fallback-cadence CADENCE Write the per-class fallback timer cadence as a blank-reset drop-in (#925).
fallback-proven-live F7 gate: the canon fallback wake is proven live (schedulable floor / firing) (#925).
validate-targets Fail-closed HMAC-key + alarm-target validate (v).
validate-hmac-key HMAC key resolves by-name, else fail loud.
validate-alarm-target Alarm sink configured + reachable, else fail loud.