docs/superpowers/specs/2026-09-04-v1-scope-design.md
248 lines · 11781 bytes
1# v1.0 scope and design
2
3Date: 2026-09-04
4Status: approved, not yet implemented
5Baseline: v0.6.1 (`e8edd54`)
6
7## What 1.0 means
8
91.0 is a personal daily-driver guarantee: **the daemon runs unattended for
10weeks, recovers from its own failures, and says so when it cannot.** It is not
11a feature-completeness marker and not an API-stability promise to third
12parties.
13
14The repository is public and has a Homebrew formula, but that came out of
15packaging, not a decision to court users. Nothing here commits to supporting
16anyone else's renderer.
17
18### In scope
19
20- Reliability: supervision, restart, and health for every collector.
21- `CollectorUp` health signals on the bus (`SCHEMA_VERSION = 5`).
22- A second TUI face, `terminal-pet`.
23- A README and roadmap that stop promising five faces at 1.0.
24
25### Out of scope, explicitly post-1.0
26
27The menu-bar permadeath pet, the sonifier, and the wallpaper renderer. The
28README currently leads with "One backend, five faces" while shipping one; that
29line is rewritten rather than met.
30
31## Problem statement
32
33Four failure modes were found in v0.6.1. Three share a shape: the daemon
34degrades silently and never recovers, which is exactly what "unattended for
35weeks" cannot tolerate.
36
37| Failure | Location | Effect |
38|---|---|---|
39| 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. |
40| 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. |
41| `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. |
42| 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. |
43
44Handled already, for the record: a stale socket does not lock out restart —
45`publish::serve` unlinks before bind (`lib.rs:461`).
46
47## Design
48
49### 1. Schema v5 and the cache key
50
51`hub.rs:80` keys the last-value cache as `(name.to_u8(), tag)`. `source` is not
52part of the key, so two signals differing only by source overwrite each other.
53This is latent today because no two names share a discriminant, but it blocks
54one health variant disambiguated by source.
55
56- `Key` becomes `(name, source, tag)` in `hub.rs`, and the same in the garden's
57 `latest` map (`terminal-garden/src/main.rs:68`).
58- `SignalName::CollectorUp` at discriminant **11**; `SCHEMA_VERSION = 5`.
59- Value is `1.0` up, `0.0` down, matching `Charging`'s existing convention.
60- Untagged. `allows_tag` stays the four git names, so no new `Tag`
61 constructors — `Tag::bundle_id` and `Tag::ssh_host` were deleted in v0.5 and
62 do not come back.
63- The supervisor publishes one `CollectorUp` per source it owns: `Git`,
64 `Terminal`, `Hardware`.
65
66Rejected: `GitCollectorUp` / `TerminalCollectorUp` / `HardwareCollectorUp`.
67It avoids touching the hub but spends three discriminants at the version where
68they become permanent, and leaves the cache-key flaw to bite something else.
69
70Appending a variant is what the v4 freeze permits — the rule is append-only,
71prohibiting renumbering and removal, not addition. One README line changes:
72v4 becomes v5 as the 1.0 contract.
73
74### 2. The supervisor
75
76New module `crates/signald/src/supervisor.rs`. It owns liveness for all three
77collectors and is the only thing that publishes `CollectorUp`.
78
79**Git and terminal** stay in one producer thread. Each collector call is
80wrapped in `catch_unwind`; a panic is caught, publishes that source down, and
81the tick continues to the next collector. The next tick retries. `terminal
82.collect` takes `&mut self` so it needs `AssertUnwindSafe`, and on a panic the
83supervisor replaces the `Collector` with a fresh one — its state is the
84active-session map, so rebuilding costs one window of rate data.
85
86**The hardware collector** is a process and gets a real supervision loop:
87spawn, ingest until EOF, publish `Hardware` down, back off, respawn.
88
89- Backoff starts at 1s and doubles to a 60s cap.
90- Backoff resets after a run that survived 30s, so a flapping child cannot
91 ratchet the delay to its ceiling permanently.
92- **No retry limit.** A transient IOKit failure at hour 3 must not disable
93 hardware until a human notices.
94
95**Never configured is not the same as failed.** If `--collector` is absent and
96nothing is on `PATH`, publish `Hardware` down once and start no retry loop.
97
98**Emission policy: transitions only**, plus once at startup. Publishing `up`
99every 2s tick would put three redundant frames per tick on the bus. The
100last-value cache carries current state to subscribers that connect later, which
101is what makes the cache-key fix load-bearing.
102
103**The hang.** `run_git` gets a timeout: spawn rather than `output()`, read on a
104thread, `recv_timeout`, kill the child on expiry, treat it as a failed call.
105A stuck `git` is otherwise unrecoverable — a Rust thread cannot be safely
106killed, so a watchdog could report the hang but never clear it. Scoped to
107`run_git` only, not a general subprocess policy.
108
109`publish::serve` still owns the socket and still blocks `main`. No change to
110the hub's fan-out, the history store, or retention.
111
112### 3. Clock and spool
113
114**The clock claim is wrong; fix the claim.** `Signal.ts` is documented as
115"Unix millis, monotonic-corrected" and is plain `SystemTime`. `ts` cannot be
116both monotonic and a true Unix timestamp: it is persisted, retention prunes on
117`ts < cutoff`, and `CommitsToday` means since local midnight. Drop the claim
118and note that consumers must not assume monotonicity.
119
120**The real bug is the rate floor.** `Session::keys_per_min` guards the
121backwards case already — `saturating_sub` plus `dt_ms > 0` degrades to
122reporting the raw key count. It has no floor on `dt_ms`, so two flushes 1 ms
123apart give `keys * 60000 / 1`, a five-figure rate from a few fast newlines.
124Floor `dt_ms` at 1000 ms; below a second, report the raw count.
125
126**The spool bound belongs in the hook**, the only place that bounds growth at
127the source.
128
129- Before appending, check the spool's size with `zsh/stat` (`zstat -A`); `wc -c`
130 would fork on every prompt.
131- Truncate if it exceeds **1 MiB**, roughly 25k records.
132- Truncate rather than rotate: a megabyte of backlog describes sessions
133 `ACTIVE_WINDOW_MS` would discard anyway.
134- The new code reads a file size only, so `forbidden_symbol_scan` is
135 unaffected, and the differential secret-typing test already drives this hook.
136
137### 4. `signal-client`
138
139`default_socket_path` is duplicated in `signald/src/main.rs` and
140`terminal-garden/src/main.rs`, with a Paths table and a pinned test in each
141binary keeping the copies honest. A third renderer makes a third copy.
142
143New crate `crates/signal-client/`: socket-path resolution and a `Frames`
144iterator over the socket yielding `wire::Frame`. Both renderers use both parts;
145`signald` depends on it for `default_socket_path` alone. Three copies and two
146duplicate tests collapse to one definition and one test.
147
148The name describes the dominant use. `signald` depending on a crate called
149"client" for one function is mildly odd, and the alternative — leaving
150`signald` its own copy — was rejected because two definitions drift as readily
151as three.
152
153This is a simplification, not a new abstraction, and the second renderer is
154what justifies it. It cannot live in `signal-schema`, which is deliberately
155dependency-free and is the privacy boundary.
156
157### 5. `terminal-pet`
158
159New crate `crates/terminal-pet/`, mirroring the garden: `lib.rs` holds the pure
160`signals -> PetState -> render` path and its unit tests, `main.rs` is the
161subscribe-and-redraw loop.
162
163Inputs are everything no face renders today: `KeysPerMin`, `SessionSeconds`,
164`CpuLoad`, `ThermalState`, `BatteryPct`, `Charging`, `BatteryDrawW`, and
165`CollectorUp` for all three sources. After this every collector has a consumer.
166
167State model, four axes resolved into one expression:
168
169- *energy* from `KeysPerMin` — asleep / calm / busy
170- *stress* from `ThermalState` and `CpuLoad`
171- *condition* from `BatteryPct` and `Charging`
172- *sick* — any `CollectorUp` at `0.0`, overriding the rest
173
174Sick is the payoff for section 1: a pet that visibly cannot feel its own
175hardware is the ambient degradation signal, and the reason health went on the
176bus rather than into a log. Freshness is the second half — a signal whose `ts`
177is more than **30 seconds** old renders dimmed, so "up but stalled" reads
178differently from "down". Thirty seconds is comfortably past the 2s default
179producer tick and the collector's own interval, so it does not flicker on a
180healthy system.
181
182Stateless: a pure function of the current snapshot. No persistence, no
183animation timeline, no config file.
184
185## Documentation
186
187The v5 bump repeats the v4 drill: `SCHEMA_VERSION` and its contract paragraph,
188the Swift `SCHEMA_VERSION` constant and its test, the canonical frame's
189`04 00` -> `05 00` in both `hardware_wire.rs` and `WireTests.swift`, and the
190byte table in `macos-collector/README.md`. Swift gains no `CollectorUp` case —
191health is published by the supervisor, not the collector — so Swift changes are
192version-only.
193
194The README needs more than a version edit:
195
196- The five-faces diagram distinguishes what exists from what is planned.
197- The phase plan gains a **v1.0** entry: a daemon that self-heals and reports
198 its own health, plus two TUI faces.
199- Menu-bar pet, sonifier and wallpaper move explicitly to post-1.0.
200- The Paths table collapses to one entry now that `signal-client` owns
201 resolution.
202- Workspace layout gains `signal-client/`, `terminal-pet/`, `supervisor.rs`.
203
204## Testing
205
206No CI; the pre-push hook is the gate.
207
208- **Supervisor:** backoff schedule as a pure unit test, including the
209 30s-survival reset. Panic containment tested against the `catch_unwind`
210 wrapper with a closure that panics, not by breaking a real collector. An
211 integration test that kills the hardware child and asserts the respawn and
212 the `Hardware` down-then-up transitions.
213- **`run_git` timeout:** point it at a sleeping command; assert it fails within
214 budget and leaves no orphan process.
215- **Hub:** rewrite `cache_keeps_latest_per_name`, and add a test that the same
216 name with two different sources coexists rather than overwriting — the
217 regression the whole design leans on.
218- **Rate floor:** `dt_ms = 1` no longer produces a five-figure rate.
219- **Spool cap:** drive the hook past 1 MiB and assert truncation.
220- **Schema:** `v4_names_are_exactly_zero_through_ten` becomes `0..=11`;
221 `only_git_repo_path_names_allow_a_tag` gains `CollectorUp` to the untagged
222 list.
223- **Pet:** pure `signals -> PetState` tests, including sick-overrides-everything
224 and stale-`ts` dimming.
225- **`signal-client`:** one socket-path test replacing the two duplicates.
226
227Privacy invariants need no change: `CollectorUp` is an untagged `f64` and adds
228no content channel.
229
230## Delivery
231
232A stack of five MRs, each targeting the one below, matching the v0.4 and v0.5
233pattern:
234
2351. Schema v5 and the hub cache key, carrying the schema-level doc changes:
236 `SCHEMA_VERSION`, the Swift constant, both canonical frames, and the
237 `macos-collector` byte table.
2382. `signal-client` extraction, garden migrated.
2393. Supervisor, `run_git` timeout, rate floor, spool cap.
2404. `terminal-pet`.
2415. README and roadmap rewrite — the narrative changes only: the faces diagram,
242 the phase plan, the Paths table, workspace layout.
243
244Then bump to 1.0.0, tag, and close the v1.0.0 milestone.
245
246## Unchanged
247
248No CI for this repository, and the gitbay runner scoping stays as it is.