Add the v1.0 scope design !25
1 file changed, +248 −0
docs/superpowers/specs/2026-09-04-v1-scope-design.md added +248
| @@ -0,0 +1,248 @@ | ||
| 1 | # v1.0 scope and design | |
| 2 | ||
| 3 | Date: 2026-09-04 | |
| 4 | Status: approved, not yet implemented | |
| 5 | Baseline: v0.6.1 (`e8edd54`) | |
| 6 | ||
| 7 | ## What 1.0 means | |
| 8 | ||
| 9 | 1.0 is a personal daily-driver guarantee: **the daemon runs unattended for | |
| 10 | weeks, recovers from its own failures, and says so when it cannot.** It is not | |
| 11 | a feature-completeness marker and not an API-stability promise to third | |
| 12 | parties. | |
| 13 | ||
| 14 | The repository is public and has a Homebrew formula, but that came out of | |
| 15 | packaging, not a decision to court users. Nothing here commits to supporting | |
| 16 | anyone 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 | ||
| 27 | The menu-bar permadeath pet, the sonifier, and the wallpaper renderer. The | |
| 28 | README currently leads with "One backend, five faces" while shipping one; that | |
| 29 | line is rewritten rather than met. | |
| 30 | ||
| 31 | ## Problem statement | |
| 32 | ||
| 33 | Four failure modes were found in v0.6.1. Three share a shape: the daemon | |
| 34 | degrades silently and never recovers, which is exactly what "unattended for | |
| 35 | weeks" 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 | ||
| 44 | Handled 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 | |
| 52 | part of the key, so two signals differing only by source overwrite each other. | |
| 53 | This is latent today because no two names share a discriminant, but it blocks | |
| 54 | one 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 | ||
| 66 | Rejected: `GitCollectorUp` / `TerminalCollectorUp` / `HardwareCollectorUp`. | |
| 67 | It avoids touching the hub but spends three discriminants at the version where | |
| 68 | they become permanent, and leaves the cache-key flaw to bite something else. | |
| 69 | ||
| 70 | Appending a variant is what the v4 freeze permits — the rule is append-only, | |
| 71 | prohibiting renumbering and removal, not addition. One README line changes: | |
| 72 | v4 becomes v5 as the 1.0 contract. | |
| 73 | ||
| 74 | ### 2. The supervisor | |
| 75 | ||
| 76 | New module `crates/signald/src/supervisor.rs`. It owns liveness for all three | |
| 77 | collectors and is the only thing that publishes `CollectorUp`. | |
| 78 | ||
| 79 | **Git and terminal** stay in one producer thread. Each collector call is | |
| 80 | wrapped in `catch_unwind`; a panic is caught, publishes that source down, and | |
| 81 | the 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 | |
| 83 | supervisor replaces the `Collector` with a fresh one — its state is the | |
| 84 | active-session map, so rebuilding costs one window of rate data. | |
| 85 | ||
| 86 | **The hardware collector** is a process and gets a real supervision loop: | |
| 87 | spawn, 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 | |
| 96 | nothing is on `PATH`, publish `Hardware` down once and start no retry loop. | |
| 97 | ||
| 98 | **Emission policy: transitions only**, plus once at startup. Publishing `up` | |
| 99 | every 2s tick would put three redundant frames per tick on the bus. The | |
| 100 | last-value cache carries current state to subscribers that connect later, which | |
| 101 | is what makes the cache-key fix load-bearing. | |
| 102 | ||
| 103 | **The hang.** `run_git` gets a timeout: spawn rather than `output()`, read on a | |
| 104 | thread, `recv_timeout`, kill the child on expiry, treat it as a failed call. | |
| 105 | A stuck `git` is otherwise unrecoverable — a Rust thread cannot be safely | |
| 106 | killed, 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 | |
| 110 | the 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 | |
| 116 | both monotonic and a true Unix timestamp: it is persisted, retention prunes on | |
| 117 | `ts < cutoff`, and `CommitsToday` means since local midnight. Drop the claim | |
| 118 | and note that consumers must not assume monotonicity. | |
| 119 | ||
| 120 | **The real bug is the rate floor.** `Session::keys_per_min` guards the | |
| 121 | backwards case already — `saturating_sub` plus `dt_ms > 0` degrades to | |
| 122 | reporting the raw key count. It has no floor on `dt_ms`, so two flushes 1 ms | |
| 123 | apart give `keys * 60000 / 1`, a five-figure rate from a few fast newlines. | |
| 124 | Floor `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 | |
| 127 | the 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 | |
| 141 | binary keeping the copies honest. A third renderer makes a third copy. | |
| 142 | ||
| 143 | New crate `crates/signal-client/`: socket-path resolution and a `Frames` | |
| 144 | iterator 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 | |
| 146 | duplicate tests collapse to one definition and one test. | |
| 147 | ||
| 148 | The 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 | |
| 151 | as three. | |
| 152 | ||
| 153 | This is a simplification, not a new abstraction, and the second renderer is | |
| 154 | what justifies it. It cannot live in `signal-schema`, which is deliberately | |
| 155 | dependency-free and is the privacy boundary. | |
| 156 | ||
| 157 | ### 5. `terminal-pet` | |
| 158 | ||
| 159 | New 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 | |
| 161 | subscribe-and-redraw loop. | |
| 162 | ||
| 163 | Inputs 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 | ||
| 167 | State 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 | ||
| 174 | Sick is the payoff for section 1: a pet that visibly cannot feel its own | |
| 175 | hardware is the ambient degradation signal, and the reason health went on the | |
| 176 | bus rather than into a log. Freshness is the second half — a signal whose `ts` | |
| 177 | is more than **30 seconds** old renders dimmed, so "up but stalled" reads | |
| 178 | differently from "down". Thirty seconds is comfortably past the 2s default | |
| 179 | producer tick and the collector's own interval, so it does not flicker on a | |
| 180 | healthy system. | |
| 181 | ||
| 182 | Stateless: a pure function of the current snapshot. No persistence, no | |
| 183 | animation timeline, no config file. | |
| 184 | ||
| 185 | ## Documentation | |
| 186 | ||
| 187 | The v5 bump repeats the v4 drill: `SCHEMA_VERSION` and its contract paragraph, | |
| 188 | the 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 | |
| 190 | byte table in `macos-collector/README.md`. Swift gains no `CollectorUp` case — | |
| 191 | health is published by the supervisor, not the collector — so Swift changes are | |
| 192 | version-only. | |
| 193 | ||
| 194 | The 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 | ||
| 206 | No 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 | ||
| 227 | Privacy invariants need no change: `CollectorUp` is an untagged `f64` and adds | |
| 228 | no content channel. | |
| 229 | ||
| 230 | ## Delivery | |
| 231 | ||
| 232 | A stack of five MRs, each targeting the one below, matching the v0.4 and v0.5 | |
| 233 | pattern: | |
| 234 | ||
| 235 | 1. 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. | |
| 238 | 2. `signal-client` extraction, garden migrated. | |
| 239 | 3. Supervisor, `run_git` timeout, rate floor, spool cap. | |
| 240 | 4. `terminal-pet`. | |
| 241 | 5. README and roadmap rewrite — the narrative changes only: the faces diagram, | |
| 242 | the phase plan, the Paths table, workspace layout. | |
| 243 | ||
| 244 | Then bump to 1.0.0, tag, and close the v1.0.0 milestone. | |
| 245 | ||
| 246 | ## Unchanged | |
| 247 | ||
| 248 | No CI for this repository, and the gitbay runner scoping stays as it is. | |