# 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. - `CollectorUp` health 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. - `Key` becomes `(name, source, tag)` in `hub.rs`, and the same in the garden's `latest` map (`terminal-garden/src/main.rs:68`). - `SignalName::CollectorUp` at discriminant **11**; `SCHEMA_VERSION = 5`. - Value is `1.0` up, `0.0` down, matching `Charging`'s existing convention. - Untagged. `allows_tag` stays the four git names, so no new `Tag` constructors — `Tag::bundle_id` and `Tag::ssh_host` were deleted in v0.5 and do not come back. - The supervisor publishes one `CollectorUp` per 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 -c` would 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_MS` would discard anyway. - The new code reads a file size only, so `forbidden_symbol_scan` is 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 `ThermalState` and `CpuLoad` - *condition* from `BatteryPct` and `Charging` - *sick* — any `CollectorUp` at `0.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-client` owns 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_unwind` wrapper with a closure that panics, not by breaking a real collector. An integration test that kills the hardware child and asserts the respawn and the `Hardware` down-then-up transitions. - **`run_git` timeout:** 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 = 1` no longer produces a five-figure rate. - **Spool cap:** drive the hook past 1 MiB and assert truncation. - **Schema:** `v4_names_are_exactly_zero_through_ten` becomes `0..=11`; `only_git_repo_path_names_allow_a_tag` gains `CollectorUp` to the untagged list. - **Pet:** pure `signals -> PetState` tests, including sick-overrides-everything and stale-`ts` dimming. - **`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: 1. Schema v5 and the hub cache key, carrying the schema-level doc changes: `SCHEMA_VERSION`, the Swift constant, both canonical frames, and the `macos-collector` byte table. 2. `signal-client` extraction, garden migrated. 3. Supervisor, `run_git` timeout, rate floor, spool cap. 4. `terminal-pet`. 5. 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.