whiskers-ports
The ports of Whiskers' backend, in Whiskers' own words.
Whiskers' backend (what whiskersd is today) has to run in more than one place: as the native binary on a
machine the operator owns, on Cloudflare (a Worker with one Durable Object per household), or self-hosted
on celld, which runs the same Worker artifact. This crate is the interface all of them implement. It is
Whiskers' vocabulary and it is owned by Whiskers: it names a household's memory, its journal, its
allowance, the judge and the voice, and it never names a hosting product. Cloudflare, celld, wrangler,
Durable Objects, buckets and fetch are things an adapter knows; a new host is a new implementation of
these traits, never an edit to them.
It builds for wasm32-unknown-unknown and, to keep that true, contains no files, no sockets, no threads
and no system clock. Time is a port (Clock); I/O is a port. Its only dependencies are serde,
serde_json and log, plus whiskers-core for the documents (the core's file-backed code is compiled
out on wasm, see its README).
The ports
Everything 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:
| Port | In Whiskers' words | Shape |
|---|---|---|
Hub<Document = MemorySnapshot> (MemoryHub) | what Whiskers remembers | merge(theirs) -> Merged { document, changed }, current() |
Hub<Document = Household> (HouseholdHub) | the grown-ups' choices and the day's time | the same |
Hub<Document = ChatState> (ChatHub) | the one running session | the same |
Journal | the one append-only log of everything said | held(device), append(device, at, lines) -> Accepted / Misaligned, pull(since) -> Page |
PictureShelf | the pictures that go with memories | has, put -> Stored / AlreadyKept, get, over SafePictureId |
Allowance | what may be spent | thinking: thinking_room, charge_thinking, thinking_usage; voice: reserve_voice, settle_voice, voice_spend |
Judge | Jev's decisions | check(direction, age, text) -> Verdict, rerank(query, candidates) -> probabilities |
Embedder | text to vectors | embed(EmbedBatch) -> vectors |
Voice | the natural voice | configured, speak(SpeechLine) -> Audio, speak_timed -> TimedAudio (audio + a LineTiming: one start and end in AudioMs per character), credits |
Thinker | the language model | serves() -> ModelName, think(ThinkRequest) -> Reply |
Secrets | credentials, by name | get(SecretName) -> Absent / Empty / Present |
Clock | the time | now() -> Millis |
Each outbound port has its own typed error that says why (SpeakError, JudgeError, ThinkError,
EmbedError, CreditsError): not configured, too long, upstream refused with a status, unreachable,
unreadable. Storage failures are StoreError::{Unavailable, Damaged}. Where a refusal is the service's own
policy rather than an adapter's (the thinking window is spent, today's voice characters are spent) it is a
typed value too: ThinkingRoom::Spent { frees_up_at }, ReserveError::DailyCap { spent, cap, frees_up_at }.
A Diagnostic carries what the other side said, for people and logs; it is never branched on.
Secrets is used by the adapters of the outbound ports, not by the service, so it is not a capability of the adapter.
Capabilities: an adapter has what it holds, and serves what it has
An adapter is a type that holds some ports and says which by implementing one small trait per capability:
HasMemory, HasHousehold, HasChat, HasJournal, HasPictures, HasAllowance, HasJudge, HasEmbedder, HasVoice,
HasThinker, HasClock. Each names the port's type and lends a reference to it (fn journal(&self) -> &Self::Journal). Nothing
makes an adapter hold them all: the native whiskersd holds every one; the Worker holds the three hubs and nothing else.
Backend is only the name for "has every capability" (a blanket-implemented supertrait of all eleven), for the adapter that is
complete.
What an adapter serves is then a question the compiler answers, twice:
- Routes.
whiskers-servicegives each route a type whoseRoute<C>impl is bounded by exactly the capabilities the route needs (impl<C: HasJudge + HasClock> Route<C> for Check). A service is built by giving it routes (Service::new(a).serve(Check)), andservedoes not compile for anawithoutHasJudge. A route that was not given is a 404, exactly like an unknown path; there is no handler to run and no "not configured" answer to forget.Service::complete(a)gives every route and requires aBackend, so a capability added toBackendstops every complete adapter compiling until it has it. - Laws.
whiskers-conformancehas one fixture trait per capability (MemoryFixture,JournalFixture, ...) and aSuitewhose method for a capability's laws applies only to a harness with that fixture. A harness without a journal cannot select the journal's laws: not skipped, not selectable.Suite::completeruns every law and needs every fixture.
Why this shape. The first design had one Backend with eleven associated types and a Service<B: Backend>. The Worker spike
needed three, and had to write 107 lines of stand-in ports (unported.rs) whose only job was to say "not here" so the service
could be built; and a request for a route it could not serve was answered with a stand-in's failure, a 200 Unavailable for
/check, which a device reads as Jev's own answer. Both are the wrong states being representable. Two splits were weighed:
- A service per route group (
SyncService<H, J, P>,JevService<J, E>,SpeechService<V, A, S>, composed by the adapter). Rejected: the groups share ports (the household's choices are read by the sync route, the speech route, the thinking route and 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, or clone it, which for a hub whose atomicity is one object's single thread is exactly what must not happen. A recipe for composing them is also something to get wrong in each adapter. - One service over one adapter, routes as types, the route set a type-level list (chosen). The adapter owns every port once
and implements the capability traits; each route says what it needs in its bounds; the compiler checks the join. The cost is a
nested type (
Complete, named once inwhiskers-servicefor code that stores a complete service) and that the adapter lists the routes it serves instead of the compiler inferring them from its trait impls (stable Rust has no way to include a route only if a bound holds). That is the right place for the choice anyway: an adapter that has a capability may still choose not to serve its route, and one that serves it cannot do so without the capability.
The rule: adding a capability is a trait, its laws and its routes.
- The
Has…trait here, added toBackend's supertraits (and its blanket impl). - The laws in
whiskers-conformance: a fixture trait, aSuitemethod, a line inFixturesandSuite::complete, and a harness fixture in the in-memory adapter (MemFixtures) and in each adapter's own tests. A capability with nothing a conformance run can show without calling out (today the embedder and the thinker) is said so in thefixture.rsheader, not left silent. - The routes in
whiskers-service: a route type whoseRouteimpl is bounded by it,Service::completeand theCompletealias updated, the path intests/service.rs'sEVERY_ROUTE.
Migration from the one Backend. An adapter that did impl Backend for X { type Memory = ...; fn memory(&self) ... } splits
that block into one impl HasMemory for X { type Memory = ...; fn memory(&self) ... } per capability it really holds and
deletes the rest (its stand-ins). Service::new(x) becomes Service::complete(x) if x is complete, or
Service::new(x).serve(RouteA).serve(RouteB) for the routes it serves; code that named Service<X> names Service<X, Complete>.
A conformance harness that called suite::hubs::memory_hub(&fresh, &restart) may keep doing so (the law functions are
unchanged) or implement the fixture traits and use Suite.
Why the hubs are merge, not get/put
A get/put pair makes atomicity every caller's problem and every adapter's silent gap: two devices syncing at
once each read, merge locally and write, and one loses. So the write side is one verb: merge this in,
atomically, and give me the converged result. Each adapter guarantees the atomicity its own way: a mutex and
an atomic file replace natively; a Durable Object's single thread of execution in a Worker; the one node that
owns the object under celld. current() exists for decisions that read a choice (the token window, the
voice cap) and never as half of a read-modify-write.
The merge rule is not the adapter's. It is a pure function in whiskers-core (MemoryDoc::merge,
Household::merge, ChatState::merge), so every backend converges to the same document and an adapter only
decides where the result is kept.
The same reasoning shapes the other ports:
Journal::appendtakes the cursor the device believes it is at. Lines are taken only if that is what the journal holds for the device, atomically; otherwise nothing is taken and the device is told the real count. Two appends at one cursor take exactly one. (The service before this crate did the read-count-then-append with no lock, so two pushes from one device at the same moment could both land.)- The voice allowance is reserve-then-settle. The characters of a line are taken from today's cap before it is spoken and given back if it does not come out, so two lines asked at once can never both pass a cap with room for one. The thinking window is the opposite on purpose: what a question costs is only known after the answer, so it is asked first and charged after, and the last question may overshoot.
- Illegal inputs cannot be constructed.
DeviceName,EntryLine,SafePictureId,SpeechLine,EmbedBatch,ThinkRequestcan only be made by passing the checks, so an adapter is never handed a path that escapes the picture directory, a journal line that is not one JSON object, an empty or oversized line to speak, or a model request wider than Whiskers allows. ADeviceCursor(one device's lines) and aLogCursor(a position in the whole log) are different types, as areMillisandDay.
Why async
The Worker target cannot be blocking: a Durable Object's storage and fetch are asynchronous, so a port that
returned a value directly could not be implemented on it at all. The native target blocks, so it needs the
async trait to cost nothing. Both are met by async fn in the traits with no Send bound: a Worker's
futures hold JavaScript values and are not Send, so the traits must not demand it (the lint that asks for
a bound is expected with that reason on each trait).
Native adapters do their blocking work inside the async fn and never actually wait, so every future they
make is Ready on its first poll; whiskersd drives them with run_ready (here, std only, panics on
Pending rather than spinning) on the thread that already serves the request. The Worker adapter is driven
by the Worker runtime. The ports are not boxed: an adapter is a build-time choice, Service<C, R> is
generic over it, and dyn with Send would be exactly the demand the Worker cannot meet.
Clock is the exception and is synchronous: every backend answers it from memory. Secrets is async
because a Cloudflare Secrets Store binding is awaited.
What every future adapter must guarantee
The contracts are written on each trait and asserted by the conformance suite in whiskers-conformance;
an adapter is not done until the suite passes against it. In short:
- One writer per household. Every stateful port must behave as if exactly one thing at a time writes a
household's state: locks in one process natively; one Durable Object per household on Cloudflare (so every
mergeis atomic by construction and no operation may be spread across two objects); the same object, owned by exactly one node, under celld. Hubs, journal, pictures and allowance for one household must be serialised together, or two stateful ports could disagree about what happened. - Durable once it returns
Ok; a failed write leaves the stored state as it was. A restart must not grant a fresh allowance. - Never log content. Words, facts, summaries, keys and ids that act as keys are not logged; lengths, counts and statuses are.
- Credentials by
SecretName. The same operator-facing names on every backend. An adapter states plainly where the value ends up (celld has no secret store; Cloudflare does).
Decided (2026-10-04)
- The timed voice is Whiskers' own shape.
TimedAudiois audio plus aLineTiming: the line's text with aSpan(start and end, inAudioMs) for each character, built so the two cannot disagree in length.LineTiming::newrefuses an empty text, a count that is not one span per character, a span that ends before it starts, and times that run backwards;TimedAudio::newrefuses a timing of other text than the line spoken. A character is a Unicode scalar value, the unitSpeechLine::charsand the allowance count in. An adapter converts its vendor's alignment into it and a reply it cannot convert isSpeakError::Unreadable; nothing vendor-shaped crosses the port. The wire isPOST /speak/v2(see the service README). Embedderis its own port, not a method ofJudge: it is a local model server today, nothing she says should leave that machine for it, and a hosted backend has no such machine.
Open design questions
JudgeError::NoAnswer("no answer from Jev") is the one wire text that differs from before: a Jev call that timed out used to read "no Jev client".
Files
allowance.rs, capability.rs (the Has… traits and Backend), embed.rs, error.rs, exec.rs (run_ready), hub.rs, journal.rs,
judge.rs, pictures.rs, secrets.rs, think.rs, time.rs, timing.rs (LineTiming, TimedAudio), voice.rs; the contracts are the doc comments
on the traits.