1# whiskers-ports 2 3The ports of Whiskers' backend, in Whiskers' own words. 4 5Whiskers' backend (what `whiskersd` is today) has to run in more than one place: as the native binary on a 6machine the operator owns, on Cloudflare (a Worker with one Durable Object per household), or self-hosted 7on celld, which runs the same Worker artifact. This crate is the **interface** all of them implement. It is 8Whiskers' vocabulary and it is owned by Whiskers: it names a household's memory, its journal, its 9allowance, the judge and the voice, and it never names a hosting product. Cloudflare, celld, wrangler, 10Durable Objects, buckets and `fetch` are things an *adapter* knows; a new host is a new implementation of 11these traits, never an edit to them. 12 13It builds for `wasm32-unknown-unknown` and, to keep that true, contains no files, no sockets, no threads 14and no system clock. Time is a port (`Clock`); I/O is a port. Its only dependencies are `serde`, 15`serde_json` and `log`, plus `whiskers-core` for the documents (the core's file-backed code is compiled 16out on wasm, see its README). 17 18## The ports 19 20Everything a household's backend can offer. Each is a **capability**, named by a `Has…` trait (see below), and an adapter has the ones it holds: 21 22| Port | In Whiskers' words | Shape | 23|---|---|---| 24| `Hub<Document = MemorySnapshot>` (`MemoryHub`) | what Whiskers remembers | `merge(theirs) -> Merged { document, changed }`, `current()` | 25| `Hub<Document = Household>` (`HouseholdHub`) | the grown-ups' choices and the day's time | the same | 26| `Hub<Document = ChatState>` (`ChatHub`) | the one running session | the same | 27| `Journal` | the one append-only log of everything said | `held(device)`, `append(device, at, lines) -> Accepted / Misaligned`, `pull(since) -> Page` | 28| `PictureShelf` | the pictures that go with memories | `has`, `put -> Stored / AlreadyKept`, `get`, over `SafePictureId` | 29| `Allowance` | what may be spent | thinking: `thinking_room`, `charge_thinking`, `thinking_usage`; voice: `reserve_voice`, `settle_voice`, `voice_spend` | 30| `Judge` | Jev's decisions | `check(direction, age, text) -> Verdict`, `rerank(query, candidates) -> probabilities` | 31| `Embedder` | text to vectors | `embed(EmbedBatch) -> vectors` | 32| `Voice` | the natural voice | `configured`, `speak(SpeechLine) -> Audio`, `speak_timed -> TimedAudio` (audio + a `LineTiming`: one start and end in `AudioMs` per character), `credits` | 33| `Thinker` | the language model | `serves() -> ModelName`, `think(ThinkRequest) -> Reply` | 34| `Secrets` | credentials, by name | `get(SecretName) -> Absent / Empty / Present` | 35| `Clock` | the time | `now() -> Millis` | 36 37Each outbound port has its own typed error that says why (`SpeakError`, `JudgeError`, `ThinkError`, 38`EmbedError`, `CreditsError`): not configured, too long, upstream refused with a status, unreachable, 39unreadable. Storage failures are `StoreError::{Unavailable, Damaged}`. Where a refusal is the service's own 40policy rather than an adapter's (the thinking window is spent, today's voice characters are spent) it is a 41typed value too: `ThinkingRoom::Spent { frees_up_at }`, `ReserveError::DailyCap { spent, cap, frees_up_at }`. 42A `Diagnostic` carries what the other side said, for people and logs; it is never branched on. 43 44`Secrets` is used by the adapters of the outbound ports, not by the service, so it is not a capability of the adapter. 45 46## Capabilities: an adapter has what it holds, and serves what it has 47 48An adapter is a type that holds some ports and says which by implementing one small trait per capability: 49`HasMemory`, `HasHousehold`, `HasChat`, `HasJournal`, `HasPictures`, `HasAllowance`, `HasJudge`, `HasEmbedder`, `HasIcons` (the cache of the picture names' vectors), `HasVoice`, 50`HasThinker`, `HasClock`. Each names the port's type and lends a reference to it (`fn journal(&self) -> &Self::Journal`). Nothing 51makes an adapter hold them all: the native `whiskersd` holds every one; the Worker holds the three hubs and nothing else. 52`Backend` is only the name for "has every capability" (a blanket-implemented supertrait of all eleven), for the adapter that is 53complete. 54 55What an adapter serves is then a question the compiler answers, twice: 56 57- **Routes.** `whiskers-service` gives each route a type whose `Route<C>` impl is bounded by exactly the capabilities the route 58 needs (`impl<C: HasJudge + HasClock> Route<C> for Check`). A service is built by giving it routes (`Service::new(a).serve(Check)`), 59 and `serve` does not compile for an `a` without `HasJudge`. A route that was not given is a 404, exactly like an unknown path; 60 there is no handler to run and no "not configured" answer to forget. `Service::complete(a)` gives every route and requires a 61 `Backend`, so a capability added to `Backend` stops every complete adapter compiling until it has it. 62- **Laws.** `whiskers-conformance` has one fixture trait per capability (`MemoryFixture`, `JournalFixture`, ...) and a `Suite` whose 63 method for a capability's laws applies only to a harness with that fixture. A harness without a journal cannot select the 64 journal's laws: not skipped, not selectable. `Suite::complete` runs every law and needs every fixture. 65 66**Why this shape.** The first design had one `Backend` with eleven associated types and a `Service<B: Backend>`. The Worker spike 67needed three, and had to write 107 lines of stand-in ports (`unported.rs`) whose only job was to say "not here" so the service 68could be built; and a request for a route it could not serve was answered with a stand-in's failure, a 200 `Unavailable` for 69`/check`, which a device reads as Jev's own answer. Both are the wrong states being representable. Two splits were weighed: 70 71- *A service per route group* (`SyncService<H, J, P>`, `JevService<J, E>`, `SpeechService<V, A, S>`, composed by the adapter). 72 Rejected: the groups share ports (the household's choices are read by the sync route, the speech route, the thinking route and 73 the usage route; the clock by five), so the adapter would build one value per group and hand each a reference to the same hub, 74 or clone it, which for a hub whose atomicity is one object's single thread is exactly what must not happen. A recipe for 75 composing them is also something to get wrong in each adapter. 76- *One service over one adapter, routes as types, the route set a type-level list* (chosen). The adapter owns every port once 77 and implements the capability traits; each route says what it needs in its bounds; the compiler checks the join. The cost is a 78 nested type (`Complete`, named once in `whiskers-service` for code that stores a complete service) and that the adapter lists 79 the routes it serves instead of the compiler inferring them from its trait impls (stable Rust has no way to include a route 80 *only if* a bound holds). That is the right place for the choice anyway: an adapter that has a capability may still choose 81 not to serve its route, and one that serves it cannot do so without the capability. 82 83**The rule: adding a capability is a trait, its laws and its routes.** 84 851. The `Has…` trait here, added to `Backend`'s supertraits (and its blanket impl). 862. The laws in `whiskers-conformance`: a fixture trait, a `Suite` method, a line in `Fixtures` and `Suite::complete`, and a harness 87 fixture in the in-memory adapter (`MemFixtures`) and in each adapter's own tests. A capability with nothing a conformance run 88 can show without calling out (today the embedder and the thinker) is said so in the `fixture.rs` header, not left silent. 893. The routes in `whiskers-service`: a route type whose `Route` impl is bounded by it, `Service::complete` and the `Complete` alias 90 updated, the path in `tests/service.rs`'s `EVERY_ROUTE`. 91 92**Migration from the one `Backend`.** An adapter that did `impl Backend for X { type Memory = ...; fn memory(&self) ... }` splits 93that block into one `impl HasMemory for X { type Memory = ...; fn memory(&self) ... }` per capability it really holds and 94deletes the rest (its stand-ins). `Service::new(x)` becomes `Service::complete(x)` if `x` is complete, or 95`Service::new(x).serve(RouteA).serve(RouteB)` for the routes it serves; code that named `Service<X>` names `Service<X, Complete>`. 96A conformance harness that called `suite::hubs::memory_hub(&fresh, &restart)` may keep doing so (the law functions are 97unchanged) or implement the fixture traits and use `Suite`. 98 99## Why the hubs are `merge`, not get/put 100 101A get/put pair makes atomicity every caller's problem and every adapter's silent gap: two devices syncing at 102once each read, merge locally and write, and one loses. So the write side is one verb: *merge this in, 103atomically, and give me the converged result*. Each adapter guarantees the atomicity its own way: a mutex and 104an atomic file replace natively; a Durable Object's single thread of execution in a Worker; the one node that 105owns the object under celld. `current()` exists for decisions that *read* a choice (the token window, the 106voice cap) and never as half of a read-modify-write. 107 108The merge **rule** is not the adapter's. It is a pure function in `whiskers-core` (`MemoryDoc::merge`, 109`Household::merge`, `ChatState::merge`), so every backend converges to the same document and an adapter only 110decides where the result is kept. 111 112The same reasoning shapes the other ports: 113 114- **`Journal::append` takes the cursor the device believes it is at.** Lines are taken only if that is 115 what the journal holds for the device, atomically; otherwise nothing is taken and the device is told the 116 real count. Two appends at one cursor take exactly one. (The service before this crate did the 117 read-count-then-append with no lock, so two pushes from one device at the same moment could both land.) 118- **The voice allowance is reserve-then-settle.** The characters of a line are taken from today's cap 119 *before* it is spoken and given back if it does not come out, so two lines asked at once can never both 120 pass a cap with room for one. The thinking window is the opposite on purpose: what a question costs is only 121 known after the answer, so it is asked first and charged after, and the last question may overshoot. 122- **Illegal inputs cannot be constructed.** `DeviceName`, `EntryLine`, `SafePictureId`, `SpeechLine`, 123 `EmbedBatch`, `ThinkRequest` can only be made by passing the checks, so an adapter is never handed a path 124 that escapes the picture directory, a journal line that is not one JSON object, an empty or oversized line 125 to speak, or a model request wider than Whiskers allows. A `DeviceCursor` (one device's lines) and a 126 `LogCursor` (a position in the whole log) are different types, as are `Millis` and `Day`. 127 128## Why async 129 130The Worker target cannot be blocking: a Durable Object's storage and `fetch` are asynchronous, so a port that 131returned a value directly could not be implemented on it at all. The native target blocks, so it needs the 132async trait to cost nothing. Both are met by `async fn` in the traits with **no `Send` bound**: a Worker's 133futures hold JavaScript values and are not `Send`, so the traits must not demand it (the lint that asks for 134a bound is `expect`ed with that reason on each trait). 135 136Native adapters do their blocking work inside the `async fn` and never actually wait, so every future they 137make is `Ready` on its first poll; `whiskersd` drives them with `run_ready` (here, std only, panics on 138`Pending` rather than spinning) on the thread that already serves the request. The Worker adapter is driven 139by the Worker runtime. The ports are not boxed: an adapter is a build-time choice, `Service<C, R>` is 140generic over it, and `dyn` with `Send` would be exactly the demand the Worker cannot meet. 141 142`Clock` is the exception and is synchronous: every backend answers it from memory. `Secrets` is async 143because a Cloudflare Secrets Store binding is awaited. 144 145## What every future adapter must guarantee 146 147The contracts are written on each trait and asserted by the conformance suite in `whiskers-conformance`; 148an adapter is not done until the suite passes against it. In short: 149 150- **One writer per household.** Every stateful port must behave as if exactly one thing at a time writes a 151 household's state: locks in one process natively; one Durable Object per household on Cloudflare (so every 152 `merge` is atomic by construction and no operation may be spread across two objects); the same object, owned 153 by exactly one node, under celld. Hubs, journal, pictures and allowance for one household must be 154 serialised together, or two stateful ports could disagree about what happened. 155- **Durable once it returns `Ok`;** a failed write leaves the stored state as it was. A restart must not 156 grant a fresh allowance. 157- **Never log content.** Words, facts, summaries, keys and ids that act as keys are not logged; lengths, 158 counts and statuses are. 159- **Credentials by `SecretName`.** The same operator-facing names on every backend. An adapter states 160 plainly where the value ends up (celld has no secret store; Cloudflare does). 161 162## Decided (2026-10-04) 163 164- **The timed voice is Whiskers' own shape.** `TimedAudio` is audio plus a `LineTiming`: the line's text with a 165 `Span` (start and end, in `AudioMs`) for each character, built so the two cannot disagree in length. `LineTiming::new` 166 refuses an empty text, a count that is not one span per character, a span that ends before it starts, and times that run 167 backwards; `TimedAudio::new` refuses a timing of other text than the line spoken. A character is a Unicode scalar value, 168 the unit `SpeechLine::chars` and the allowance count in. An adapter converts its vendor's alignment into it and a reply it 169 cannot convert is `SpeakError::Unreadable`; nothing vendor-shaped crosses the port. The wire is `POST /speak/v2` (see the 170 service README). 171- **`Embedder` is its own port**, not a method of `Judge`: it is a local model server today, nothing she says should leave 172 that machine for it, and a hosted backend has no such machine. 173 174## Open design questions 175 176- `JudgeError::NoAnswer` ("no answer from Jev") is the one wire text that differs from before: a Jev call 177 that timed out used to read "no Jev client". 178 179## Files 180 181`allowance.rs`, `capability.rs` (the `Has…` traits and `Backend`), `embed.rs`, `error.rs`, `exec.rs` (`run_ready`), `hub.rs`, `journal.rs`, 182`judge.rs`, `pictures.rs`, `secrets.rs`, `think.rs`, `time.rs`, `timing.rs` (`LineTiming`, `TimedAudio`), `voice.rs`; the contracts are the doc comments 183on the traits.