docs/superpowers/specs/2026-09-04-v1-scope-design.md
248 lines · 11781 bytes
v1.0 scope and design
Date: 2026-09-04
Status: approved, not yet implemented
Baseline: v0.6.1 (e8edd54)
What 1.0 means
1.0 is a personal daily-driver guarantee: the daemon runs unattended for weeks, recovers from its own failures, and says so when it cannot. It is not a feature-completeness marker and not an API-stability promise to third parties.
The repository is public and has a Homebrew formula, but that came out of packaging, not a decision to court users. Nothing here commits to supporting anyone else's renderer.
In scope
- Reliability: supervision, restart, and health for every collector.
CollectorUphealth signals on the bus (SCHEMA_VERSION = 5).- A second TUI face,
terminal-pet. - A README and roadmap that stop promising five faces at 1.0.
Out of scope, explicitly post-1.0
The menu-bar permadeath pet, the sonifier, and the wallpaper renderer. The README currently leads with "One backend, five faces" while shipping one; that line is rewritten rather than met.
Problem statement
Four failure modes were found in v0.6.1. Three share a shape: the daemon degrades silently and never recovers, which is exactly what "unattended for weeks" cannot tolerate.
| Failure | Location | Effect |
|---|---|---|
| Hardware collector never respawned | signald/src/lib.rs:409 |
Child dies once; hardware signals stop permanently. KeepAlive does not help because signald itself is still alive. |
| Producer thread unsupervised | signald/src/main.rs:94 |
A panic in git::collect or terminal.collect kills the thread. The socket keeps serving a frozen last-value cache, so faces render plausible numbers that stopped being true. |
run_git has no timeout |
signald/src/lib.rs |
.output() blocks forever on a stuck lock or a network mount. Health would read "up" while nothing updates. |
| Spool unbounded while daemon is down | shell-hooks/signald-hooks.zsh |
The hook appends every prompt with nothing consuming it; recovery reads the whole file into memory. |
Handled already, for the record: a stale socket does not lock out restart —
publish::serve unlinks before bind (lib.rs:461).
Design
1. Schema v5 and the cache key
hub.rs:80 keys the last-value cache as (name.to_u8(), tag). source is not
part of the key, so two signals differing only by source overwrite each other.
This is latent today because no two names share a discriminant, but it blocks
one health variant disambiguated by source.
Keybecomes(name, source, tag)inhub.rs, and the same in the garden'slatestmap (terminal-garden/src/main.rs:68).SignalName::CollectorUpat discriminant 11;SCHEMA_VERSION = 5.- Value is
1.0up,0.0down, matchingCharging's existing convention. - Untagged.
allows_tagstays the four git names, so no newTagconstructors —Tag::bundle_idandTag::ssh_hostwere deleted in v0.5 and do not come back. - The supervisor publishes one
CollectorUpper source it owns:Git,Terminal,Hardware.
Rejected: GitCollectorUp / TerminalCollectorUp / HardwareCollectorUp.
It avoids touching the hub but spends three discriminants at the version where
they become permanent, and leaves the cache-key flaw to bite something else.
Appending a variant is what the v4 freeze permits — the rule is append-only, prohibiting renumbering and removal, not addition. One README line changes: v4 becomes v5 as the 1.0 contract.
2. The supervisor
New module crates/signald/src/supervisor.rs. It owns liveness for all three
collectors and is the only thing that publishes CollectorUp.
Git and terminal stay in one producer thread. Each collector call is
wrapped in catch_unwind; a panic is caught, publishes that source down, and
the tick continues to the next collector. The next tick retries. terminal .collect takes &mut self so it needs AssertUnwindSafe, and on a panic the
supervisor replaces the Collector with a fresh one — its state is the
active-session map, so rebuilding costs one window of rate data.
The hardware collector is a process and gets a real supervision loop:
spawn, ingest until EOF, publish Hardware down, back off, respawn.
- Backoff starts at 1s and doubles to a 60s cap.
- Backoff resets after a run that survived 30s, so a flapping child cannot ratchet the delay to its ceiling permanently.
- No retry limit. A transient IOKit failure at hour 3 must not disable hardware until a human notices.
Never configured is not the same as failed. If --collector is absent and
nothing is on PATH, publish Hardware down once and start no retry loop.
Emission policy: transitions only, plus once at startup. Publishing up
every 2s tick would put three redundant frames per tick on the bus. The
last-value cache carries current state to subscribers that connect later, which
is what makes the cache-key fix load-bearing.
The hang. run_git gets a timeout: spawn rather than output(), read on a
thread, recv_timeout, kill the child on expiry, treat it as a failed call.
A stuck git is otherwise unrecoverable — a Rust thread cannot be safely
killed, so a watchdog could report the hang but never clear it. Scoped to
run_git only, not a general subprocess policy.
publish::serve still owns the socket and still blocks main. No change to
the hub's fan-out, the history store, or retention.
3. Clock and spool
The clock claim is wrong; fix the claim. Signal.ts is documented as
"Unix millis, monotonic-corrected" and is plain SystemTime. ts cannot be
both monotonic and a true Unix timestamp: it is persisted, retention prunes on
ts < cutoff, and CommitsToday means since local midnight. Drop the claim
and note that consumers must not assume monotonicity.
The real bug is the rate floor. Session::keys_per_min guards the
backwards case already — saturating_sub plus dt_ms > 0 degrades to
reporting the raw key count. It has no floor on dt_ms, so two flushes 1 ms
apart give keys * 60000 / 1, a five-figure rate from a few fast newlines.
Floor dt_ms at 1000 ms; below a second, report the raw count.
The spool bound belongs in the hook, the only place that bounds growth at the source.
- Before appending, check the spool's size with
zsh/stat(zstat -A);wc -cwould fork on every prompt. - Truncate if it exceeds 1 MiB, roughly 25k records.
- Truncate rather than rotate: a megabyte of backlog describes sessions
ACTIVE_WINDOW_MSwould discard anyway. - The new code reads a file size only, so
forbidden_symbol_scanis unaffected, and the differential secret-typing test already drives this hook.
4. signal-client
default_socket_path is duplicated in signald/src/main.rs and
terminal-garden/src/main.rs, with a Paths table and a pinned test in each
binary keeping the copies honest. A third renderer makes a third copy.
New crate crates/signal-client/: socket-path resolution and a Frames
iterator over the socket yielding wire::Frame. Both renderers use both parts;
signald depends on it for default_socket_path alone. Three copies and two
duplicate tests collapse to one definition and one test.
The name describes the dominant use. signald depending on a crate called
"client" for one function is mildly odd, and the alternative — leaving
signald its own copy — was rejected because two definitions drift as readily
as three.
This is a simplification, not a new abstraction, and the second renderer is
what justifies it. It cannot live in signal-schema, which is deliberately
dependency-free and is the privacy boundary.
5. terminal-pet
New crate crates/terminal-pet/, mirroring the garden: lib.rs holds the pure
signals -> PetState -> render path and its unit tests, main.rs is the
subscribe-and-redraw loop.
Inputs are everything no face renders today: KeysPerMin, SessionSeconds,
CpuLoad, ThermalState, BatteryPct, Charging, BatteryDrawW, and
CollectorUp for all three sources. After this every collector has a consumer.
State model, four axes resolved into one expression:
- energy from
KeysPerMin— asleep / calm / busy - stress from
ThermalStateandCpuLoad - condition from
BatteryPctandCharging - sick — any
CollectorUpat0.0, overriding the rest
Sick is the payoff for section 1: a pet that visibly cannot feel its own
hardware is the ambient degradation signal, and the reason health went on the
bus rather than into a log. Freshness is the second half — a signal whose ts
is more than 30 seconds old renders dimmed, so "up but stalled" reads
differently from "down". Thirty seconds is comfortably past the 2s default
producer tick and the collector's own interval, so it does not flicker on a
healthy system.
Stateless: a pure function of the current snapshot. No persistence, no animation timeline, no config file.
Documentation
The v5 bump repeats the v4 drill: SCHEMA_VERSION and its contract paragraph,
the Swift SCHEMA_VERSION constant and its test, the canonical frame's
04 00 -> 05 00 in both hardware_wire.rs and WireTests.swift, and the
byte table in macos-collector/README.md. Swift gains no CollectorUp case —
health is published by the supervisor, not the collector — so Swift changes are
version-only.
The README needs more than a version edit:
- The five-faces diagram distinguishes what exists from what is planned.
- The phase plan gains a v1.0 entry: a daemon that self-heals and reports its own health, plus two TUI faces.
- Menu-bar pet, sonifier and wallpaper move explicitly to post-1.0.
- The Paths table collapses to one entry now that
signal-clientowns resolution. - Workspace layout gains
signal-client/,terminal-pet/,supervisor.rs.
Testing
No CI; the pre-push hook is the gate.
- Supervisor: backoff schedule as a pure unit test, including the
30s-survival reset. Panic containment tested against the
catch_unwindwrapper with a closure that panics, not by breaking a real collector. An integration test that kills the hardware child and asserts the respawn and theHardwaredown-then-up transitions. run_gittimeout: point it at a sleeping command; assert it fails within budget and leaves no orphan process.- Hub: rewrite
cache_keeps_latest_per_name, and add a test that the same name with two different sources coexists rather than overwriting — the regression the whole design leans on. - Rate floor:
dt_ms = 1no longer produces a five-figure rate. - Spool cap: drive the hook past 1 MiB and assert truncation.
- Schema:
v4_names_are_exactly_zero_through_tenbecomes0..=11;only_git_repo_path_names_allow_a_taggainsCollectorUpto the untagged list. - Pet: pure
signals -> PetStatetests, including sick-overrides-everything and stale-tsdimming. signal-client: one socket-path test replacing the two duplicates.
Privacy invariants need no change: CollectorUp is an untagged f64 and adds
no content channel.
Delivery
A stack of five MRs, each targeting the one below, matching the v0.4 and v0.5 pattern:
- Schema v5 and the hub cache key, carrying the schema-level doc changes:
SCHEMA_VERSION, the Swift constant, both canonical frames, and themacos-collectorbyte table. signal-clientextraction, garden migrated.- Supervisor,
run_gittimeout, rate floor, spool cap. terminal-pet.- README and roadmap rewrite — the narrative changes only: the faces diagram, the phase plan, the Paths table, workspace layout.
Then bump to 1.0.0, tag, and close the v1.0.0 milestone.
Unchanged
No CI for this repository, and the gitbay runner scoping stays as it is.