Docs Decision records

DocsReference

#Architecture decision records

One file per decision, numbered, never edited after acceptance (supersede instead).

Numbers are allocated in blocks so that two codebases can never claim the same one — which is what happened to 0102 while auth lived in its own repository (ADR 0013):

BlockSubject
0000–0099the harness itself
0100–0199cross-cutting harness concerns
0200–0299the auth service
0300–0399the control plane

#The harness

ADR 0000 records why the TypeScript v1 was discarded. ADR 0011 supersedes the naming half of ADR 0005: public crates are cratefield-*, renamed before the first crates.io release. ADR 0012 extends 0011: the bare cratefield name is published as a facade crate, not held as a placeholder. ADR 0013 supersedes the distribution half of 0005 and 0011: every crate lives in this repository, and publish = false — not a separate repository — is what makes a crate private. ADR 0014 amends ADR 0006 without retracting it: the signer's two secret slots become a bounded key ring with revocation as a state, expiry becomes a mint-time policy, tokens gain a venture/environment binding, and the never-dies unsubscribe link gains an opaque, per-subscription revocable form. ADR 0015 extends ADR 0002 for the Push port: a recipient is an enum with one variant per transport, platform() is a transport fact rather than a device fact, and the routing combinator sits above the adapters in core. ADR 0016 records the two answers the notifications module (#182) needed and the harness did not have: a dead letter is a module-owned table rather than a new terminal state on core's Outbox, and a module learns the calling account from cratefield-auth-client's Authenticated extractor — the first module to depend on it.

ADR 0017 decides how events cross a sidecar boundary (#62): inbound only, forwarded to POST /__events where ports are resolved rather than through a Scope change that would bump HARNESS_API, carrying exactly the guarantees the in-process bus already gave. Bidirectional was rejected for the cycle it admits and the delivery question it forces.

ADR 0018 decides how a composite-key declared table is addressed (#387): the key is named in the query at /{table}/__by rather than in the path, because one shared router serves every declared table and a static segment shadows {key} — so the reservation is made on purpose and the row it strands is given the query form back. Per-arity path segments, a key in a request body, and refusing the shape in fz build were rejected. The decision is recorded only: nothing ships yet, and the three routes still answer 400 composite-key.

ADR 0019 decides how a performance change is admitted (#158): a step ships only when a published benchmark shows the cost it removes and is reverted if that number does not move, and the order is region-pinning the primary, immutable reads from KV or the cache, Durable Object warmers, then D1 read replication last — last because the assessment that ranked replication first was reasoning about one large shared database, and the harness ships one small D1 per venture, so the read volume at any primary is one venture's, not the platform's. Replication first, all four steps at once, pinning on the intuition that nearer is faster, and latency targets for the four steps set before any measurement were rejected. The decision is recorded only: nothing ships until the numbers behind the first three gates are measured — including step 2's immutable-read share, which no benchmark counts yet.

ADR 0020 puts the write half of the tenant registry in core (#154): TenantLifecycle — create, list, status, set_status, with begin_erasure/complete_erasure carrying offboarding's erasure half — implemented by the Postgres adapter over the calls that already existed, so a CLI built against core is runtime-neutral by construction. It is deliberately not a Port: modules must never write the registry, the transition rule stays TenantStatus::admits (#361), and provisioning or dropping a database stays an operator step, because ADR 0008 scopes a tenant's app role to one database and the harness holds no credential that could do either. On D1 the tenant is the venture, so the 10 GB cap is a venture-level threshold and promotion is a venture-level move — there is no per-tenant lifecycle event for leaving D1, and none will be.

ADR 0021 decides what the MCP server is allowed to be (#160): a thin wrapper over fz --json, living in this workspace as crates/mcp rather than a published repository. Six of the seven tools spawn the venture's own fz in the venture's directory and hand back the one JSON object it printed byte for byte; the seventh, fz_error_codes, serves the catalogue in-process — read at runtime, never copied. A TypeScript server beside tools/cfjs and a richer API of its own were rejected — each is a second place that can disagree with the CLI.

ADR 0022 admits a cheaper classifier only on measurement (#457). Nothing routes to the cheap adapter by default. RoutingClassifier sends it a kind of question only when a committed AgreementReport shows that this pair of adapters agree on that kind. The agreement is measured at the thresholds the router will use, and those thresholds belong to the cheap adapter and name its Calibration. A threshold tuned under another calibration is refused at wiring. Unsure cheap answers escalate together in one second call. Off and pinned modes bypass routing. Every call is recorded with estimated tokens, because the port reports no usage, and integer prices, where an unpriced adapter is unknown rather than free.

ADR 0023 puts a scheduled invocation on a cooperative budget (#537): ModuleContext::scheduled is the module's share of the tick's wall time and subrequests, split by the runtime with unspent share rolling forward, checked by the module (never cancelled by it). Outbox:: drain_within is the "N due items per tick" drain, and reschedule is the per-item cursor for recurring polling. Cloudflare Queues fan-out is left for a follow-up.

ADR 0024 makes nearest-neighbour search a port of its own (#561): VectorIndex answers upsert, query and delete, and query takes a k and an equality VectorFilter and returns one normalised cosine number, higher closer, so a module can threshold a result without learning a vendor's metric or scale. An upsert replaces the whole record rather than merging it, and deleting an id that is not there is not an error. Every call carries a VectorNamespace, and the normal constructor is VectorNamespace::for_tenant — the same scoping a table has, so a query never crosses tenants even where both corpora share one index. Embeddings get their own Embedder port rather than TextModel::embed, which keeps tools, streaming and embeddings out of the TextModel shape; the model name rides with the vectors, because vectors from two models are not comparable, and the input token count rides with them because that is what a usage report bills.

ADR 0025 moves the billing lifecycle out of venture code and into a module (#592). RevenueCat is not a Payments adapter — it moves no money — so store purchases get their own InAppPurchases port (Port::InAppPurchases, first adapter cratefield-adapter-revenuecat); provider-neutral lifecycle events, states and a revenue ledger live in a new billing module of cratefield-core; and one published module (cratefield-module-billing) owns the subscriptions, grants, disputes, flags and the two webhook routes. A webhook is a trigger and the REST API is the truth; Stripe stays the authority for Stripe money. What an entitlement unlocks, its prices, and a flagged account's fate beyond the configured default stay venture code.

ADR 0026 moves a Supabase project onto the harness (#657): one native-only phase engine (cratefield-import-supabase, not yet written) drives both fz import supabase and a dashboard button, and both produce the same plan and the same report. The Supabase public schema becomes a per-venture app schema that harness migrations do not manage, so ADR 0008's no-RLS and no-search_path rules hold; data streams through COPY with a checksum per table, auth users move through #650's import path (bcrypt kept for verify-and-upgrade, the Supabase id kept as the external id), and objects go to R2. RLS policies and everything else the database cannot carry are reported, never guessed, and each needs a disposition before a cutover that is the DATA-MOVE switch — with a rollback window and a generated decommission checklist.

ADR 0027 keeps a Merchant of Record inside the Payments port and reads disputes through it (#690), on the port shapes #589 and #602 set out without implementing their Stripe halves. Polar is a Payments adapter rather than a new port: it moves money the way the port means — hosted checkout, refunds, metered usage — and an operation a MoR does not offer (create_connect_account_link, charge_with_transfer) answers PaymentsError::Unsupported rather than a silent no-op. The port gains only additive methods with defaults, so every existing implementation compiles unchanged and HARNESS_API stays 1: create_portal_session, get_dispute, close_dispute, and list_disputes — the last one because Polar sends no dispute webhook, so a scheduled poll is the only way a dispute is ever seen to open — plus verify_webhook_request, defaulting to Stripe-Signature, so one handler serves every adapter.

ADR 0028 makes inbound mail a verified source beside the outbound one (#563) and gives the runtime a way to serve a module's surface over MCP. Inbound mail is a verify-then-parse trait in core (InboundMailSource, with kind(), verifier() and parse()) rather than a Port variant: a delivery is pushed at a route with a per-endpoint secret, so a Port entry would touch Port::ALL, Ports, both runtimes and every bundle for a service a module reaches through a webhook and never through module.ports(). receive_mail verifies before it parses, so parse never sees unverified bytes, and the shape is deliberately tracker's StatusWebhook/receive_status. The outbound Mailer stays the port; the inbound half is its mirror. Verification is core's, deduplication belongs to the Inbox.

ADR 0029 gives the harness a voice (#861): a Speech port — transcribe, synthesize, voices, capabilities, limits — asked for by capability and never by vendor, with every request checked against local ceilings (25 MiB, ten minutes, 4 096 characters — OpenAI's documented caps) before any network call, and a SpeechUsage of milliseconds transcribed and characters synthesised riding back on every answer, so a spend cap meters in the provider's own units. Synthesis answers a ResponseStream that may start yielding before the clip is finished. The two adapters are new modules of existing adapter crates — the Workers AI binding (Deepgram nova-3 and Whisper in, Aura out, truly streaming) and the OpenAI-compatible audio wire — not new crates, and the binding-backed one is not a facade feature, for the same worker-crate reason as the classifier's. OpenAI-compatible synthesis stays buffered until a streaming HttpClient (#859); the Port::Speech variant and Ports wiring are a follow-up.

ADR 0030 puts every mail through one themed layout (#715) and lets the theme travel by composition and config rather than through core. One crate owns the look — cratefield-mail-templates renders a Message with a MailTheme into an HTML part and a plain-text twin, depends only on core, serde and tracing, and builds for wasm32 — while a module describes only what the mail says. The theme reaches the templates beside them: each mail-sending module exports themed_templates(&MailTheme) next to default_templates(), returning the same template ids carrying the venture's theme, so a venture composes its style exactly where it already registers templates, one line per module. The two modules that had rendered outside the registry (module-orgs, module-notifications) now render through it, which also makes them overridable like the rest. No locked contract moves: the Module trait, ModuleContext, Brand and HARNESS_API are untouched. Filed as 0030 because it shared 0028 with the inbound-mail record while both were being drafted; see the note inside it.

0102 chose the crypto crate: RustCrypto's chacha20poly1305.

0103 accepts a versioned Worker-secret KEK as the production KMS (#535): HARNESS_KEK_CURRENT names the version that wraps, HARNESS_KEK_V1 onward hold the material, and unwrap dispatches on the authenticated version header in the blob — so rotation is put the next version, point, redeploy, re-wrap, delete. It is not an HSM KMS and is not claimed as one: the Worker isolate can read the KEK material. The Worker path adopting the tenant secrets store over it is a follow-up, not decided there.

#The auth service

Numbered from 0200. These were 0100–0104 in Factory-Zero/auth; they were renumbered when that repository was folded in (ADR 0013), because 0102 was already taken here. Each spike produced one, and so does any decision a login method forces.

ADRDecision
0200Pure-Rust WebAuthn verification, because webauthn-rs cannot reach wasm32
0201Signed ES256 JWTs with a published JWKS, over opaque tokens
0202Apple's minted client secret, form_post cookie policy and one-time name
0203The login chooser lists configured methods, and this service ships the passkey page because nothing else can
0204Meta is OAuth 2.0 with a Graph profile call, and its email is never stored as verified

#The control plane

Its architecture and pricing are written up in docs/control-plane/; it has no ADRs of its own yet. They take the 0300 block when it does.

Mirrored from docs/adr/README.md at b6f50cd. The harness is the source of truth: a correction goes there first.