README.mdpreviewREADME.mdsource183 lines · 14.1 KB · raw

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:

PortIn Whiskers' wordsShape
Hub<Document = MemorySnapshot> (MemoryHub)what Whiskers remembersmerge(theirs) -> Merged { document, changed }, current()
Hub<Document = Household> (HouseholdHub)the grown-ups' choices and the day's timethe same
Hub<Document = ChatState> (ChatHub)the one running sessionthe same
Journalthe one append-only log of everything saidheld(device), append(device, at, lines) -> Accepted / Misaligned, pull(since) -> Page
PictureShelfthe pictures that go with memorieshas, put -> Stored / AlreadyKept, get, over SafePictureId
Allowancewhat may be spentthinking: thinking_room, charge_thinking, thinking_usage; voice: reserve_voice, settle_voice, voice_spend
JudgeJev's decisionscheck(direction, age, text) -> Verdict, rerank(query, candidates) -> probabilities
Embeddertext to vectorsembed(EmbedBatch) -> vectors
Voicethe natural voiceconfigured, speak(SpeechLine) -> Audio, speak_timed -> TimedAudio (audio + a LineTiming: one start and end in AudioMs per character), credits
Thinkerthe language modelserves() -> ModelName, think(ThinkRequest) -> Reply
Secretscredentials, by nameget(SecretName) -> Absent / Empty / Present
Clockthe timenow() -> 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-service gives each route a type whose Route<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)), and serve does not compile for an a without HasJudge. 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 a Backend, so a capability added to Backend stops every complete adapter compiling until it has it.
  • Laws. whiskers-conformance has one fixture trait per capability (MemoryFixture, JournalFixture, ...) and a Suite whose 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::complete runs 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 in whiskers-service for 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.

  1. The Has… trait here, added to Backend's supertraits (and its blanket impl).
  2. The laws in whiskers-conformance: a fixture trait, a Suite method, a line in Fixtures and Suite::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 the fixture.rs header, not left silent.
  3. The routes in whiskers-service: a route type whose Route impl is bounded by it, Service::complete and the Complete alias updated, the path in tests/service.rs's EVERY_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::append takes 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, ThinkRequest can 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. A DeviceCursor (one device's lines) and a LogCursor (a position in the whole log) are different types, as are Millis and Day.

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 merge is 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. TimedAudio is audio plus a LineTiming: the line's text with a Span (start and end, in AudioMs) for each character, built so the two cannot disagree in length. LineTiming::new refuses 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::new refuses a timing of other text than the line spoken. A character is a Unicode scalar value, the unit SpeechLine::chars and the allowance count in. An adapter converts its vendor's alignment into it and a reply it cannot convert is SpeakError::Unreadable; nothing vendor-shaped crosses the port. The wire is POST /speak/v2 (see the service README).
  • Embedder is its own port, not a method of Judge: 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.