carlos / todomvc-bakeoff Public
π€ The decisions β trust, shape, hosting, identity
The four axes every CARLOS app settles on day one, after tenancy (what one instance is and how people reach it: tenancy.md, which comes first because it decides the blast radius of every answer here). Each section gives the family's real positions with the reasons their own docs record β offer these as choices with trade-offs, not as one right answer. Whatever is chosen: date it, attribute it, record the rejected alternative, and publish every deviation. Retrofitting any of these is a migration, not an edit.
1. Trust model β what can the server read?
The family default is server-blindness ("if the server is compromised, the attacker gets nothing"), but no shipped app is absolutist. The real spectrum is defined by which leaks you name: every app publishes its softenings as a list, and enforces the boundary with tests that grep raw SQLite bytes and bucket objects for plaintext. A "partial" model is legitimate exactly when the leak is named; it is a violation when hidden.
The positions, with their recorded reasons:
Each position below carries its trust class (S, A1, A2, A/pub, B, C; tenancy.md Β§3), declared per workload. Slopbox is the A1 case: padded, fixed-size blobs and encrypted names, so only transport shape leaks.
- E2EE content, plaintext routing metadata β Keymail (A2). Bodies and subjects are ciphertext; from/to/date/folder are readable, published as a trade-off ("like SMTP envelopes"). Its named softenings show what honest partial looks like: inbound ordinary email is plaintext transiently in RAM at the boundary, then sealed server-side to the mailbox's public key (sealing needs only the public half); opt-in imports state before running that messages pass through memory; mixed sends tell the user what travels unsealed. And a trade refused: searchable encryption rejected β the client legitimately holds plaintext and keys, so the index is client-side and the server learns nothing new.
- E2EE content, blind identity, public reference data β Kass (A2; its agent sidecar is C). A training log is medical-adjacent, so user content is sealed β but the exercise database is public reference data embedded in the binary, deliberately unencrypted, and the identity registry stores email only as an HMAC blind index under a pepper (the readable address lives solely in unclaimed sign-in links, blanked on claim). The bar its docs set: the schema could be published as the privacy policy β leak the whole registry and the worst anyone learns is roughly how many people signed up, and when.
- Split by content class β Woodstar (A2 + A/pub). Public posts are plaintext on the server (they are public; encrypting them would be theater), DMs are sealed end-to-end, and private state β follows, petnames, drafts, settings β is server-blind encrypted blobs, so the server never learns the social graph. Federation (ActivityPub/Bluesky) is an explicit, bounded, opt-in leak of the public class only; DM bridging is a permanent non-goal.
- Sealed single-tenant β an internal finance ledger (B). Data encrypted at rest, but the instance holds the key in memory because unattended bank syncs need plaintext with no human present. The README names the residual risk out loud ("a compromised live host can read data") and the copy never claims otherwise; the plaintext-grep test still runs, with one dated, allowlisted exception. The shape for tools that must act on the data autonomously.
- Server-side trust β Tito (C). A ticketing platform holds buyer PII in plaintext already; there is no encrypted-blob requirement to inherit, and a six-person support team must be able to read an email address (Vicky's rule: every decision is weighed by the support load it creates). What compensates is authorization, not decryption β credentials only ever on the root origin, account-authored content only on the account's own host, and operator escape hatches (2FA reset) that open doors without unlocking data.
Choosing: classify the content, not the app. Private between named parties and the product promise is "we can't read it" β seal it and eat the costs (no server search, recovery becomes product surface, key loss is data loss by design). Public or bridged to a public network β sealing it is theater; spend the effort on the private classes beside it. A support team that must read it β server trust, argued in writing from support load, repaid with origin splits and authorization design. An agent/automation that must act on it unattended β sealed single-tenant, with the residual risk in the README.
Whatever you choose: the merciless grep test from day one, the deviations list in the README, and β server-blind apps β remember the bucket: Litestream ships the whole SQLite file to object storage, so a plaintext column is a plaintext leak. The envelope mechanics for sealed content (HKDF purpose keys, ECDH β AES-GCM, ECIES-wrapped per-thread/ content keys β which is also how a sealed object gains a second named reader) are blueprint.md's crypto section; golden vectors pin every implementation. Honesty note (2026-08-23): rastrillo does not yet properly support the E2EE use-case as an app story β its crypto and webauthn packages are primitives, and framework-level support is being worked on now. A server-blind app today hand-builds the app-side flow on the Keymail/Kass/Woodstar patterns; don't claim the framework does it.
2. App shape β server-rendered or client-owned?
The family recognizes exactly two shapes; the axis is written into the framework's design (chosen at scaffold time in the rastrillo ui design; the server shape is what rastrillo new ships today, the client shape is hand-built on the Woodstar/Eleven pattern until the framework grows it).
server(the default): server-rendered HTML, zero-JS baseline as a non-negotiable. Every screen and primary action works with scripts stripped; JS is enhancement only, enforceable by a test (fetch the page script-stripped, diff for every affordance) rather than a browser. Interactive components are real HTML first (a dropdown is<details>/<summary>). This is Tito, the platform console, and every admin/CRUD/dashboard surface in the family. It is also what hibernation likes: request/response apps sleep well.client: the app runs in the browser and owns its state. Chosen when the product needs what server rendering cannot do: client-held keys and client-side crypto (sealing DMs, encrypting blobs, proof-of- work β work that must happen where the seed is), live feeds over WebSocket/SSE, and reader-owned state the server never learns. This is Woodstar: a hand-written no-build-step ES-module SPA, VanJS (140 vendored lines) as a thin reactive shell β views return{ el, update }β with local caches merged against server-blind blobs for multi-device continuity. Keymail and Kass are client-shape on their sealed surfaces for the same reason: E2EE content forces the shape, because sealing must happen where the keys are.
The client shape's price is discipline it must self-impose, because JS owns the paint. The family's evidence for why: Eleven's hand-rolled shell grew to 8,722 lines with one render function invoked from 31 call sites β the bug class a reactive binding removes structurally, and the reason VanJS is the sanctioned default (framework-free remains legitimate; anything heavier than VanJS is a redesign). Woodstar keeps the 300-line module cap test-enforced (one coordinator module may carry a higher cap; caps only ratchet down), builds all DOM through a falsy-dropping helper (a stray null child renders as the literal text "null" β a real shipped bug), gates first paint so a cold load never shows the page assembling, and runs three browser drives in CI because a JS syntax error takes the whole client down and only a real engine sees it.
Platform cost differs too. A server-shape app is one route. A client-shape product like Woodstar is several cooperating binaries with in-app path fan-out (one proxy in front of its binaries; the edge's path routes split a hostname only by its first segment, and only onto whole routes β platform.md), WebSocket upgrades and SSE proxied through two hops (verify the 101 through the real edge, never assume it), wildcard customer domains via ACME delegation, and a held socket that interacts with hibernation. All supported β none free.
Choosing: default to server. Move to client only for a product that genuinely needs client-side crypto, live channels, or server-blind reader state β not because a richer client feels more modern. The zero-JS baseline binds server-shape surfaces; a screen that cannot honor its promise without JS (sealing in the browser) is a client-shape surface, and the two compose within one product: Woodstar's status page is a plain static app beside the SPA, and a sealed app's marketing and settings pages can stay server-shape beside its client-shape editor.
3. Hosting β Carloku, your boxes, or your platform
- Carloku (hosted) β the default. Zero infra: the getting-started path. The pricing design is per app (baseline + instance-hours; hibernating apps cost ~the baseline; egress/storage metered separately; free rungs), but no price list is published yet (2026-10-18): don't quote prices or promise a free tier. Nothing about the other axes locks you out of moving later.
- Vendors: one codebase, hosted and customer-run. With one instance per customer, an on-prem request stops being a fork: the customer's instance runs on their boxes and bucket (a customer fleet) or their own platform, following your release channel. Tito proves the self-hosted half in three regions; no vendor has yet shipped to customers this way (Jelly is in development). Publish a licence class (L1 to L5, tenancy.md Β§3) so customers know what they may run.
- Customer fleets β your boxes, the hosted control plane.
carlos fleetsattaches boxes you own (a Pi, an office server, edge devices) to your account; apps place onto them withcarlos apps place. Pay per box, apps free β the family rule is you pay per app or per box, never both. The path for hardware you must own without operating a whole platform. - Self-hosted platform β Tito's path. Run the platform software yourself: own cloud account, own console, own boxes; your members use the same
carlosCLI against your console. Choose it when you must be the operator of record (residency, regulation, or the trust model itself). The cost is real operations: provisioning, platform rolls, the adopt/restart cadence, and being the one who debugs below the API. - Hand-rolled (no platform) β the historical path, now a deliberate exception. blueprint.md remains the complete recipe (it is what the platform automated), for the app that must run somewhere the platform doesn't. Keep the core close to the blueprint's shape so it can be swapped for the platform later.
4. Identity β how people sign in
Instance-per-account makes single sign-in a day-one problem (three instances must not mean three credentials). The family's current positions:
- "Sign in with Keymail" β Keymail has become the family's identity provider: an anonymous, usernameless passkey vault (the home-vault pattern, ported from Eleven's design β content-blind, one padded sealed blob per person, the vault is the instance list so the server can't count your instances). It now also speaks OAuth for third-party apps: the
client_idis the app's origin (no registration queue β DNS+TLS already establish what a registry would), PKCE, one grant per origin, and an app token authorizes delivery and identity, never reading. The hosted consoles authenticate this way, and sensitive console actions use passkey step-up that deliberately never mints a stronger credential than the caller had. - Per-instance passkeys + PRF (Kass): the WebAuthn PRF output wraps the data seed, so possession of the passkey β not the server β is the security boundary. Recovery is an explicit table of wrapped seeds: losing every row is losing the data, by design and by disclosure.
- Magic link + mandatory TOTP (Tito, server-trust): boring, support- friendly, with hashed single-use recovery codes and an operator reset verb that is safe because nothing it unlocks is encrypted under it. Identity lives beside the router, outside the per-account blast radius, and reaches instances only as signed assertions β headers are stripped-and-re-minted, and signing (not stripping alone) is what stops a peer socket from forging them.
Choosing: hosted-platform apps get console sign-in for free and should reach for rastrillo/auth before inventing anything β the family default: magic-link email that auto-upgrades to "Sign in with Keymail" when the address has a claimed inbox, so every address works. rastrillo/password is the classic email+password alternative, and step-up (sessions.RequireFresh) is the hardening seam. Server-blind apps: passkeys + PRF, and treat recovery as product surface. Server-trust apps: magic link + TOTP. Passkey RPs pin to a hostname β pick the credential origin deliberately and early (renaming an RP later is a multi-phase drill), and never share it with an origin that carries third-party or account-authored content.