README.mdpreviewREADME.mdsource183 lines · 14.1 KB · raw
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.