eleven / ocho Public

docs: define infinity personal-instance privacy and Carlos architecture

Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev ff6f2e4320ea08ab78c5a06dc0875986a7aa8111 parent 000b882
13 files changed, +2390 −376
  • .agents/skills/building-carlos-apps/SKILL.md +235 −0
  • .agents/skills/building-carlos-apps/references/blueprint.md +267 −0
  • .agents/skills/building-carlos-apps/references/decisions.md +211 −0
  • .agents/skills/building-carlos-apps/references/platform.md +459 −0
  • .agents/skills/building-carlos-apps/references/process.md +190 −0
  • .agents/skills/building-carlos-apps/references/rastrillo.md +156 −0
  • AGENTS.md +26 −0
  • DESIGN.md +25 −0
  • PLAN.md +10 −0
  • README.md +7 −376
  • CLAUDE.md => docs/infinity/eleven-upstream-instructions.md +0 −0
  • docs/infinity/eleven-upstream-readme.md +382 −0
  • docs/infinity/rastrillo-authoring.md +422 −0
diff --git a/.agents/skills/building-carlos-apps/SKILL.md b/.agents/skills/building-carlos-apps/SKILL.md
new file mode 100644
index 0000000..3a9adda
--- /dev/null
+++ b/.agents/skills/building-carlos-apps/SKILL.md
@@ -0,0 +1,235 @@
+---
+name: building-carlos-apps
+description: Use when building or designing an app on the CARLOS architecture (Cost-efficient, Available, Replicated, Lightweight, Open, Secure) or with rastrillo (the CARLOS web framework), bringing an existing app onto it, or weighing the family's open choices β€” full vs partial encryption, server-rendered vs client-owned shape, hosted vs self-hosted β€” the model extracted from Eleven Messenger, Keymail, Woodstar, Slopbox and Kass, adopted by Tito and Seapointish. For the zero-decisions happy path to a first live app, use getting-started instead.
+---
+
+# πŸ€– Building CARLOS apps
+
+## Overview
+
+CARLOS is an application architecture: **one static Go binary, one
+fully-isolated instance per account (own process, own SQLite file, own unix
+socket, own hostname), many instances on one small box behind a small
+router**, with every database continuously replicated to object storage.
+Everything below is distilled from the prompt histories, repo instruction
+files and code of the apps it was extracted from. The house style is to
+state rules as laws, date and attribute decisions, and make rules
+mechanical rather than remembered.
+
+## When to use
+
+- Starting a new product that should join the family β€” especially when it
+ needs real decisions: trust model, app shape, hosting, identity.
+- Porting an existing app onto the model (Tito's path β€” see "Adopting").
+- Any question of the form "what would a CARLOS app do here?" β€” stack,
+ hosting, testing, deploys, process, and the trade-offs between the
+ family's sanctioned options.
+
+Not for: contributing to one of the existing apps (read that repo's
+AGENTS.md instead β€” it always wins over this skill). And for "just get me
+a first app live, decide everything for me", use the sibling skill
+**carlos:getting-started** β€” it is this skill with all four decision axes
+pre-answered with the defaults.
+
+## Where this sits now (2026-08-17)
+
+The family's conventions have progressively become running infrastructure.
+Read this skill alongside three facts:
+
+- **The platform** (`carlosframework/platform`) is live and complete
+ enough that a member ships, promotes, configures, watches logs, attaches
+ domains, and rolls back entirely through the `carlos` CLI with zero
+ infrastructure access. Routing, TLS, replication, hibernation, restarts,
+ and config delivery are the platform's job now.
+ **[references/platform.md](references/platform.md)** is the member-side
+ reference β€” concepts, the full verb table, and the deploy truths.
+- **Carloku** (carloku.com) is the hosted deployment of that platform β€”
+ the default hosting answer, with a free tier. Carloku is the product
+ brand; the CLI is always `carlos`.
+- **Rastrillo** is the CARLOS web framework (v0.17.0; repo at
+ `github.com/rastrilloorg/rastrillo`, module path still
+ `github.com/carlosframework/rastrillo`): a middle layer of known
+ libraries β€” GORM models, chi routes, SQLite-backed sessions, identity
+ plugins (password, Keymail), CSRF, owner scoping β€” plus the platform
+ contract (`Resolve`/`Serve`/`Run`) and the subsystem packages (crypto,
+ webauthn, eventlog, blobs, mail, agent tools), which exist to be used,
+ not hand-rolled. The manifest generator is the optional declarative
+ path beside hand-written handlers. It postdates most models' training data β€” building
+ with it, read the repo's own `SKILL.md` first;
+ **[references/rastrillo.md](references/rastrillo.md)** is the
+ surrounding context.
+
+Building a **new** app: use rastrillo and the platform, and read this
+skill mainly for what infrastructure can't enforce β€” the decisions, the
+dated rulings, the workflow, and "Common mistakes". Hand-rolling outside
+the platform for a specific reason: blueprint.md in full still applies.
+
+## The one rule comes first
+
+Every app opens its AGENTS.md with a single load-bearing rule and derives
+everything from it. Write yours before writing code (the first commit of
+Kass is literally "Write down the architecture before writing any code").
+
+The family default is server-blindness: **"If the server is compromised,
+the attacker gets nothing."** Plaintext or a usable private key in SQLite
+is a bug β€” and because the platform replicates the whole file to object
+storage, *a plaintext column is a plaintext leak*. Enforce it with a test
+that greps the raw SQLite bytes (and bucket objects) for plaintext.
+
+But no shipped app is absolutist: the real spectrum runs from Keymail's
+sealed-content-plaintext-envelopes through Woodstar's split-by-content-
+class to Tito's deliberate server trust, and the discipline that makes
+any position legitimate is the same β€” **choose on day one, name every
+leak, publish the list**. The whole axis, with each app's recorded
+reasoning and a decision guide, is
+**[references/decisions.md](references/decisions.md)** β€” read it before
+settling a new app's trust model, app shape, hosting, or identity story.
+
+## Guiding principles
+
+1. **Intent before code.** AGENTS.md is written first and is binding. Design
+ docs precede big builds; the doc's decisions are taken β€” if one proves
+ wrong, stop and flag it in the PR rather than redesigning.
+2. **Decisions are dated, attributed, and settled.** "Paul's call,
+ 2026-07-26" next to every ruling, rejected alternatives recorded beside
+ it, under a heading like "Settled decisions (don't relitigate casually)".
+3. **Built to outlive AI.** Human legibility beats AI convenience. Never add
+ machinery only an LLM can drive; operational knowledge lives in scripts
+ and docs in the repo, never solely in prompts or agent memory. "No LLM
+ cookbooks": ops run through a command, never reconstructed from prose β€”
+ a script drifts loudly, a document drifts silently.
+4. **Rules are mechanical, not remembered.** The module-size cap is a test.
+ Contrast ratios are computed from the token file by a test. A guard a
+ shell step can satisfy is a guard a shell step can be wrong about β€” put
+ guards inside the test binary. Generated configs are marked GENERATED
+ and regenerated on a timer, never hand-edited.
+5. **Blast radius is the unit of design.** One process + one DB per account:
+ a panic takes down one person. The corollary cuts both ways: strict
+ validation belongs where its blast radius is one request, not one fleet
+ (a shared package that panics at init once 502'd every process at once).
+6. **Defer until earned, with the trigger written down.** Keep a
+ "Deliberately not built yet" section. And ship a thing *off* rather
+ than untested when the only real proof needs infrastructure you don't
+ have yet.
+7. **Don't diverge gratuitously from the family.** Shared shape is what
+ makes components extractable; a local improvement to shared code is a
+ debt to upstream, noted as such.
+8. **Honesty over polish.** Publish trade-offs plainly ("Honest trade-offs
+ β€” read before trusting it"), never overclaim maturity, and don't let
+ the UI lie: an uncertain state is a sentence, never a fabricated number,
+ and a claim about a person ("you earned your rest") has to be true.
+9. **Every deviation from the one rule is enumerated**, justified, and
+ published β€” never hidden.
+
+## The eleven factors
+
+CARLOS is the architecture; the values under it are **the eleven factors**
+([11factor.org](https://11factor.org) is the canonical text β€” link to it,
+don't copy it). Sibling repos cite factors **by number**, so know the names:
+
+I. Trust no one Β· II. Let kids play Β· III. Encrypt everything Β·
+IV. Great design is for everyone Β· V. Intent is the system Β·
+VI. Built by humanity, owned by humanity Β· VII. Self-hosting is a right Β·
+VIII. Many small things Β· IX. Inefficient builds efficient Β·
+X. Humans come first Β· XI. Centralised infrastructure is glue.
+
+The ones the family invokes operationally:
+
+- **III β€” Encrypt everything** β†’ the one rule: server-blind by default;
+ deviations are legitimate under the deviation rule, not violations.
+- **V β€” Intent is the system** β†’ AGENTS.md and design docs before code;
+ "the code should have to argue with something."
+- **VI β€” Built by humanity, owned by humanity** β†’ open source; private
+ until it works is allowed, but "factor VI is not optional, only
+ deferred."
+- **VII β€” Self-hosting is a right** β†’ self-hosting is first-class, never a
+ degraded tier; hosted convenience (Carloku) is the product, never the
+ software.
+- **VIII β€” Many small things** β†’ instance-per-account, scale by adding
+ instances, not replicas of one.
+- **X β€” Humans come first** β†’ AI is "a guest with a name tag": opt-in,
+ never required, always disclosed (πŸ€– marking), and every path works with
+ the AI switched off. The strongest form is Kass's: the AI is an ordinary
+ member whose code never runs inside the server process, and whose
+ conclusions land as proposals into human review, never as direct writes.
+
+## Quick reference β€” the shape
+
+| Concern | The CARLOS answer |
+|---|---|
+| Language | Go, one static binary, `CGO_ENABLED=0`, thin `main.go` dispatch |
+| Dependencies | Stdlib first; "adding a dependency is a decision, not a default"; hand-roll small clients over SDKs; a scoped exception is dated and does not extend to its neighbours |
+| Storage | `modernc.org/sqlite`, WAL, one DB per instance, additive-only migrations β€” automatic on rastrillo |
+| Quantities | Integer cents, integer grams β€” "a float never touches an amount" |
+| Frontend | Two shapes, chosen deliberately: `server` (server-rendered, zero-JS baseline β€” the default) or `client` (Woodstar's shape) β€” see decisions.md Β§2 |
+| JS discipline | 300-line module cap enforced by test, ratchet-down only; VanJS (vendored) the one sanctioned reactive dependency |
+| Routing, TLS, replication, hibernation, restarts | The platform's job β€” see platform.md; hand-rolled only off-platform (blueprint.md) |
+| Outbound email | `carlos email enable` β€” the platform mints the sending identity, publishes DKIM/SPF/DMARC, delivers SMTP credentials as env; never run an MTA or hold a cloud mail key β€” platform.md |
+| Scheduled work | `carlos schedule set` declares it; the app's part is a POST handler guarded by `carlos.Tick` β€” the instance is asleep, so never an in-process cron β€” platform.md |
+| Hosting | Carloku hosted (default) / customer fleets / self-hosted platform β€” decisions.md Β§3 |
+| Deploys | `carlos deploy`: ship + promote + watch `X-Carlos-Version` until live; verify against the thing you changed with the binary you built. A static site is the same one command β€” `--kind static --host <h> <dir>`, which declares the route too β€” platform.md |
+| Identity | "Sign in with Keymail" / passkeys+PRF / magic link+TOTP by trust model β€” decisions.md Β§4 |
+| Trust | Server-blind by default; the spectrum and its named softenings β€” decisions.md Β§1 |
+| UI stance | Hide the machinery: no hostnames, keys, or crypto vocabulary in the default flow β€” "no nerdspeak"; calm UI, red for danger only, no toasts |
+| Process | Worktree per session, branch, regular pushed commits; squash-merge to main only when done, approved and CI green (CI green binds once the repo has CI β€” a new repo's first merges land on done + approved and add CI early); review on a deployed canary, never localhost |
+| Authorship | πŸ€–/πŸ‘¨ markers, `Co-Authored-By: Claude …` trailers, published prompt + carbon ledgers |
+
+## Details
+
+- **[references/decisions.md](references/decisions.md)** β€” the four axes:
+ trust model, app shape, hosting, identity β€” each with the family's real
+ positions, recorded reasons, and a decision guide.
+- **[references/platform.md](references/platform.md)** β€” the platform
+ member-side: concepts (accounts, releases, channels, instances,
+ hibernation), the `carlos` CLI verb table, sending email (SMTP
+ credentials, DKIM/DMARC alignment, the credential-propagation trap),
+ scheduled work (the tick contract, idempotency, one-shot timers),
+ deploy truths, self-hosting.
+- **[references/rastrillo.md](references/rastrillo.md)** β€” building an
+ app with rastrillo: the middle-layer shape, where the authoritative
+ SKILL.md lives, the safety rules, and the declarative manifest path.
+- **[references/blueprint.md](references/blueprint.md)** β€” the technical
+ blueprint the platform automated: the carlos core, storage rules,
+ crypto conventions, box setup. The reference for self-hosting outside
+ the platform and for understanding what it does on your behalf.
+- **[references/process.md](references/process.md)** β€” working
+ conventions: repo docs, git/PR workflow, canaries, the test layers
+ (including the uidump pattern), `/end-session`, ledgers and
+ AI-authorship marking.
+
+## Adopting CARLOS in an existing app (Tito's path)
+
+1. Restate the model in your first commit and at the top of README and
+ AGENTS.md.
+2. Deploy onto the platform (hosted or self-hosted) rather than porting
+ the router/replication machinery β€” that is what it is for. Only when
+ the app must run somewhere the platform can't: take the core into
+ `internal/carlos`, kept close to blueprint.md's shape so it can be
+ swapped for the platform later.
+3. Adopt the conventions alongside (worktrees, PR playbook, canaries,
+ additive migrations, zero-JS-first).
+4. List every deferred component with its trigger; list every deliberate
+ departure with its reason.
+5. Fixes you make to shared-shape code are candidates to upstream β€” record
+ them.
+
+## Common mistakes
+
+| Mistake | Reality |
+|---|---|
+| Adding a framework/bundler "just for this screen" | The no-build-step rule is load-bearing (auditability, longevity). One more ES module, one concern. |
+| Choosing the client shape because it feels modern | The client shape is for client-held keys, live channels, and server-blind state β€” and it must self-impose the discipline the server shape gets free (decisions.md Β§2). |
+| Hand-rolling a router, certs, Litestream, or restart machinery | The platform's job. An app on the platform is a binary on a unix socket (`--socket`/`--db`; there is no `$PORT`). |
+| Giving a static site an instance | It has no process to run. The edge serves it off the channel pointer, so `carlos deploy --kind static --host <h> <dir>` is the whole deploy β€” `instances enable`/`create` is the binary recipe, and following it here builds a route that succeeds at every step and can never wake. |
+| Concluding you have no access because the console redirected you | A `curl` of the console 302s to a login page for everyone β€” a browser session is not what the CLI holds. `carlos auth whoami` is the only thing that answers the question, and it is the first command of any platform task. |
+| Guessing a channel name from the ladder in `--help` | Channels belong to the app. A new app has exactly one (`edge`), and `canary β†’ edge β†’ beta β†’ stable` is the frozen legacy ladder for apps that declare no pipeline. `carlos channels` lists this app's; `carlos pipeline` orders them. |
+| Growing the biggest file/package | A new concern gets a new small module or package β€” never more growth of the biggest one. |
+| Destructive migration "to clean up" | Migrations are additive-only. New code over an old DB must always be safe. Never delete data to update. |
+| Trusting a backup that exists | "A backup you've never restored is a hope, not a backup." (Platform-run for platform apps; your job off-platform.) |
+| Verifying a deploy with a 200 | An app-shell route 200s for any build ever shipped. Only `X-Carlos-Version` on the canonical host, after the watch, is proof β€” and minted alias hosts carry no version header by design. |
+| Treating a promote as a deploy | Until the process cycles, the old binary serves. Platform-known units cycle automatically; hibernating tenants wake into the new build; a bespoke unit stays old until something restarts it. |
+| Reviewing on localhost | Review happens on a deployed canary, always. Shared hosts only ever run merged main. |
+| Skipping the post-deploy browser check | A JS syntax error takes the whole client down and only a real engine sees it (the keymail rule). |
+| Treating a day-one absolute as unsatisfiable and quietly skipping it | A gate a brand-new project cannot meet is a gate not written for day one. Name the bootstrap case in the rule and make satisfying it early work β€” a rule agents learn to step around stops working everywhere. |
+| Relitigating a settled decision in a drive-by | Settled decisions change with new facts, dated and attributed β€” not casually. |
+| A retired stack left owning DNS or deploy paths | It can silently undo a cutover (a legacy apply once reverted a live A record). Move the records; delete the verb that can report success while changing nothing. |
diff --git a/.agents/skills/building-carlos-apps/references/blueprint.md b/.agents/skills/building-carlos-apps/references/blueprint.md
new file mode 100644
index 0000000..1ec16bf
--- /dev/null
+++ b/.agents/skills/building-carlos-apps/references/blueprint.md
@@ -0,0 +1,267 @@
+# πŸ€– CARLOS technical blueprint
+
+The stack and infrastructure shared by the family. Concrete values (instance
+types, timeouts, cap numbers) are the family's defaults β€” snapshots, not
+contracts; the shapes are the contract.
+
+> **Scope (re-drawn 2026-08-17):** this file is the *under-the-hood and
+> self-hosting* reference β€” what the platform does on an app's behalf,
+> and the complete recipe for running without it. An app **on** the
+> platform (hosted or self-hosted deployment) hand-rolls none of "The
+> carlos core", "Replication", the deploy recipe, or config delivery:
+> those are driven through the `carlos` CLI β€” see
+> [platform.md](platform.md). Bullets marked **Automatic on rastrillo**
+> are enforced by the framework. The Go server, SQLite, frontend, and
+> crypto sections bind every app regardless.
+
+## Go server
+
+- One module, one static binary: `CGO_ENABLED=0`, cross-compiled
+ (`GOOS=linux GOARCH=arm64`), `-ldflags "-s -w -X main.buildVersion=$(git
+ rev-parse --short HEAD)"`. A deploy is one file you scp.
+- `main.go` is subcommand dispatch and environment reading only β€” everything
+ real lives in `internal/`, grouped by concern. "A new concern gets a new
+ small package, not a new file in the biggest one."
+- Subcommands: a platform-deployed rastrillo app needs none β€” the binary
+ speaks `--socket`/`--db` via `rastrillo.Run` and everything operational
+ is a `carlos` verb. Self-hosted-without-platform apps grow their own
+ (`serve`, `router`, `add`/`remove`/`instances`, `ops …`, `version`);
+ ops verbs are code, not runbooks β€” and delete any ops verb the moment
+ it can report success while changing nothing (one did, for weeks).
+- HTTP is stdlib: `http.NewServeMux` with method+pattern routes
+ (`mux.HandleFunc("GET /api/thing", …)`), `writeJSON`/`httpError` helpers,
+ `http.MaxBytesReader` on every body. Wire all routes in one
+ `Server.handler()` so httptest exercises the real router.
+- Config: flags with `<APP>_*` env fallback. **Secrets are env-only, never
+ flags** (argv is visible in `ps`). On the platform they are delivered by
+ `carlos env`/`carlos secrets` (sealed, converged in seconds); ssh-stdin
+ or 0600 `EnvironmentFile`s is the off-platform form only.
+- Dependency floor: `modernc.org/sqlite` + `golang.org/x/crypto` (autocert).
+ Everything else earns its place. Hand-roll small API clients (S3 SigV4,
+ Stripe form-posts) instead of importing SDKs; if usage grows past a
+ handful of calls, swap in the SDK rather than growing the hand-rolled one.
+
+## SQLite rules (hard-won)
+
+- `modernc.org/sqlite` (pure Go β€” cgo would break the static binary).
+- One database file per instance; a separate registry DB for the router.
+- **Automatic on rastrillo.** Open with: `busy_timeout` set **before**
+ `journal_mode=WAL` (the other order crashes with SQLITE_BUSY under
+ concurrent open), then `SetMaxOpenConns(1)`, then migrate.
+- **Automatic on rastrillo.** Migrations are idempotent `CREATE TABLE IF
+ NOT EXISTS` plus an additive `ALTER` list whose errors are ignored only
+ when the message contains "duplicate column". Deploying new code over
+ an old DB must always be safe. Never delete data to update.
+- Cross-process reads: a long-lived modernc connection can serve a stale WAL
+ snapshot of another process's commits β€” read fleet-visible state (the
+ route table) over a fresh connection.
+- Registry change detection: poll a SQL fingerprint over a fresh connection
+ (`PRAGMA data_version` is blind to cross-process commits), and reload on
+ the *first* fingerprint too β€” baselining it away loses routes added during
+ startup.
+- Scale by adding instances, not replicas of one. SQLite is not the
+ bottleneck you think: 200k attendees measured at 29 MB, FTS5 search <1 ms.
+ Don't shard for "performance".
+
+## The carlos core (`internal/carlos`)
+
+**This whole section is the platform's job now** (`carlosframework/platform`,
+live), not something an app hand-rolls anymore β€” regardless of whether it
+uses rastrillo. Kept below as the reference for self-hosting outside the
+platform, or for understanding what it's doing on an app's behalf.
+
+- **Registry**: SQLite table of `host β†’ unix socket` routes (+ optional
+ slug, kind `instance|service`, lifecycle status, version). It doubles as
+ the ACME allowlist: a host nobody provisioned never gets a certificate, so
+ pointing DNS at the box entitles you to nothing.
+- **Router**: the only process bound to :80/:443. Autocert HTTP-01 with
+ `HostPolicy` = registry lookup; per-host certs, no wildcards until the
+ ~50-certs/week Let's Encrypt budget demands DNS-01. Lazy
+ `httputil.ReverseProxy` per socket with a unix `DialContext`; a retrying
+ dialer (only `ECONNREFUSED`/`ENOENT`, window sized past the instance drain
+ time) masks restarts during deploys; swap the whole route table atomically
+ on reload. For streaming responses set `FlushInterval = -1`. The router
+ answers `/healthz` and `/api/version` for itself, and **every instance
+ must also serve `GET /api/version` reporting its build sha** β€” deploy
+ verification polls all of them.
+- **Instances**: systemd template units (`app@<host>.service`), one per
+ account, `Restart=always`, socket in a tmpfiles.d-managed dir (or under
+ `/data` so a runtime-dir wipe can't yank sockets). Instances survive a
+ router restart because they are not its children.
+- If the router proxies identity, strip those headers from every inbound
+ request before minting them (test that forgery is stripped). Unix socket
+ paths must stay under ~104 bytes (`sun_path`) β€” tests use
+ `os.MkdirTemp("", "xx")`, not `t.TempDir()`.
+- Provisioning order: make the directory, create the owner, start the
+ instance, wait for it to actually answer, and only then publish the route
+ β€” publishing earlier makes the owner's first visit a 502.
+- Hibernation (live and default-on for the platform's provisioned
+ instances; build it yourself only off-platform): park sleeping
+ instances' state in S3 under a single-writer lease (one object that is
+ both lock and manifest, expiring, with fenced commit), wake on request,
+ sleep via one sweep goroutine β€” never a timer per instance.
+
+## Replication
+
+**Also the platform's job now**, for the same reason as the carlos core
+above β€” Litestream is configured and run by the platform's host agent,
+not by each app.
+
+- **Litestream streams every data-bearing SQLite DB to S3. No exceptions.**
+ "A box is disposable; the bucket is not." Adopted family-wide after a real
+ data-loss incident.
+- The litestream config is *generated* by an ops verb on a timer, never
+ hand-edited: litestream accepts a glob in `path` and then silently
+ replicates nothing β€” that exact failure happened on first provision and
+ was caught only by checking the bucket.
+- Restore drills are automated: a systemd timer on the box (daily) restores
+ every replica to a private tmp dir and requires
+ `PRAGMA integrity_check = ok` and a non-empty schema. "A backup nobody has
+ ever restored is not a backup β€” it is a folder in a bucket and a feeling."
+- The bucket is versioned, private, per-product, with IAM scoped to exactly
+ it. Deploys never touch `/data/*.db*`.
+- Canaries can restore the prod replica (`--seed-prod`) so review happens on
+ real data β€” for E2EE apps the canary holds ciphertext it cannot read.
+
+## The home vault (single sign-in across instances)
+
+Instance-per-account creates a sign-in problem by construction: a person
+with three instances must not need three credentials. This has tripped up
+every app in the family; solve it on day one, not after. The answer is
+**home** β€” a small companion service (Eleven's home server, ported whole
+into siblings rather than reinvented) giving one passkey sign-in that spans
+every instance:
+
+- Home is anonymous and content-blind. It stores credential public keys
+ and **one padded, sealed blob per person** β€” the vault of instance
+ addresses and wrapped keys, sealed in the browser β€” and nothing else.
+ Read the schema as the privacy policy.
+- Instance addresses ride the URL `#fragment` on the way through home, so
+ home's server never learns which instances a person uses.
+- When instance hostnames are hidden from users entirely, the app runs on
+ home's origin and calls the instance cross-origin with bearer tokens β€”
+ the address bar shows home, the content flows direct. **Home never
+ proxies instance traffic**: a proxy would make home load-bearing and
+ hand it a durable session→instance join. Where instances are directly
+ addressable instead, home is only the vault, not the front door.
+- Home is glue (factor XI): thin, blind, replaceable. Losing it costs a
+ convenience, never content β€” the instances still hold everything.
+- Passkeys are scoped per relying party, so pick home's hostname
+ deliberately and early: renaming an RP later is a multi-phase drill
+ (legacy RP accepted for sign-in, never registration, so the crossover
+ drains), not an edit. Never share home's origin or cookie domain with
+ anything that carries third-party content.
+- Server-trust apps (Tito's model) reach the same one-sign-in outcome
+ differently: identity lives beside the router on the credential origin
+ (the root domain), and reaches instances only as trusted headers the
+ router strips from every client request before minting its own.
+
+## Frontend
+
+- **Server-generated markup is the default, not the fallback.** The core
+ flow ships with zero JS and must work without it (confirm pages for
+ destructive actions, form posts); JS is progressive enhancement on top.
+- **Hide the machinery.** Every instance has a URL, a key, a cipher β€” the
+ person sees none of them by default. Instance hostnames are deliberately
+ meaningless (sqids, hidden behind home); no nerdspeak in the default
+ flow β€” no "instance", "key", "encrypt", "URL" in member-facing copy
+ (enforceable as a word blacklist in the browser drive); never show an
+ identifier where a name belongs β€” prompt for the name instead. The
+ scope is the *default flow* only: settings, docs, the self-hosting
+ path and the published trade-offs speak plainly β€” this hides the
+ machinery from the person who didn't ask, it never conceals how the
+ system works from the person who did.
+- When JS is needed: a small self-contained ES module β€” one file, one
+ concern, own state, no globals, no bundler. If a module needs another
+ module's internals, that's a server round-trip or a redesign, not an
+ import.
+- **Automatic on rastrillo** (the same test, carried into `carlos vet`).
+ 300-line cap per module, enforced by a test; at most one named
+ coordinator module (the store/app shell that wires modules together and
+ owns no policy of its own) may carry a higher test-enforced cap. Caps
+ only ratchet down β€” raising a number to make a build pass is the exact
+ move the test exists to prevent.
+- Storage and network are injected, never reached for β€” unit tests hand in
+ fakes; `localStorage` access from a leaf module is a test failure.
+- Never `innerHTML` untrusted content β€” build DOM nodes with a helper whose
+ falsy-dropping filter guards the leaked-value bug class.
+- When a view genuinely needs reactivity, the family's one sanctioned
+ reactive dependency is **VanJS, vendored as readable source**: views
+ return `{ el, update }` and build DOM through the same falsy-dropping
+ helper β€” in van bindings too, never bare tags. Anything heavier than
+ VanJS is a redesign, not a dependency.
+- Assets via `go:embed`, cache-busted by build version (`?v=<sha>` or
+ fingerprinted names) so a deploy is never a hard refresh.
+- If a JS dependency is truly needed, vendor it as readable source. No font
+ CDNs β€” self-host woff2. No toasts; feedback is inline.
+
+## Crypto and identity defaults
+
+- **Automatic on rastrillo** (`rastrillo/crypto`, opt-in β€” an app that
+ isn't E2EE imports nothing from it). Platform primitives only: WebCrypto
+ in the browser, stdlib/x/crypto in Go. One 32-byte seed β†’ HKDF-SHA256
+ with domain-separated info strings (`app/purpose/v1`) β†’ purpose keys.
+ ECDH P-256 β†’ HKDF β†’ AES-GCM for envelopes; ECIES to wrap per-thread/
+ content keys.
+- Secrets that must not reach the server ride the URL `#fragment`.
+- Identity: passkeys with the WebAuthn PRF extension wrapping the seed
+ (possession of the PRF output is the security boundary, not the server) β€”
+ or, for server-trust apps, magic link + mandatory TOTP. Either way: 256-bit
+ random tokens, stored only as SHA-256, never logged; rate limits keyed by
+ email, IP, and user. **Partial on rastrillo:** `rastrillo/webauthn`
+ provides registration/assertion verification primitives (ES256 only, no
+ attestation checking); the PRF-extension seed-wrapping and home-vault
+ flow described here remain hand-built.
+- **Automatic on rastrillo** for the crypto core specifically β€” the same
+ golden-vector CI discipline, promoted to the framework so it protects
+ every app that uses it from one shared fixture instead of a private
+ copy each. When the protocol exists in two or more languages (Go + JS +
+ Swift), golden vectors generated by the Go side are the spec: every
+ implementation must reproduce them, CI/`go test` compares the copies,
+ and a rule change regenerates the vectors in the same commit. Vectors
+ are not snapshots β€” never update them to make a test pass.
+- Enumeration resistance where the domain is sensitive: auth endpoints
+ answer identically whatever happened.
+
+## Boxes and deploys (self-hosting outside the platform only)
+
+**On the platform, this whole section is `carlos deploy`** β€” ship,
+promote, watch `X-Carlos-Version` β€” and box choice, TLS, and systemd
+hardening are the platform operator's concern. What follows is the
+recipe the platform automated, for running without it.
+
+- One or two tiny ARM boxes: AWS t4g.nano (prod) / t4g.micro (dev) in
+ eu-west-1, or a single Hetzner box. Dedicated cloud account per product
+ with a named CLI profile β€” and never another product's profile ("Leave
+ woodstar alone!"). A Hetzner box still replicates to an S3 bucket in the
+ product's own AWS account β€” the box choice doesn't change the bucket
+ story.
+- IaC in OpenTofu: two boxes, DNS, one bucket, IAM. State in a versioned S3
+ bucket with `use_lockfile` once more than one machine applies. On
+ hand-built boxes: `lifecycle { ignore_changes = [ami, user_data];
+ prevent_destroy = true }` β€” an AMI-drift replacement once deleted root
+ volumes. Never `tofu apply` from a checkout older than main: state removal
+ destroys resources.
+- TLS on-box: autocert inside the router with the registry as allowlist.
+ (The static-binary-Caddy-in-front fallback is historical β€” pre-router
+ apps used it; nothing current does.) No load balancer, no CDN, no
+ containers.
+- systemd hardening on every unit: dedicated user, empty
+ `CapabilityBoundingSet` (router alone keeps `CAP_NET_BIND_SERVICE`),
+ `NoNewPrivileges`, `ProtectSystem=strict` with narrow `ReadWritePaths`,
+ `PrivateTmp`, `MemoryDenyWriteExecute`, `MemoryMax`, `Restart=always`.
+- Access: prefer SSM Session Manager with no SSH port at all; on SSH boxes,
+ operator accounts over WireGuard, root bootstrap-only, sudoers as a
+ committed reviewed allowlist.
+- **Deploy = build, install, restart, VERIFY.** Fetch `origin/main`, build
+ exactly that commit in a throwaway git worktree (local edits structurally
+ cannot ship; there is no way to deploy a branch β€” that is a feature), scp
+ to a uniquely-named staged file, `install -m755`, restart router + all
+ instances, then poll `/api/version` until every instance socket reports
+ the deployed sha β€” a healthy service alone is not evidence the build won.
+ Dev first; prod after dev verifies.
+- After every deploy, run the committed headless-browser console check
+ against the live host (see process.md).
+- Mail, when needed, is SES with per-sender tenants so one user's bounces
+ can't poison everyone's deliverability. Secrets come from a password
+ manager (1Password/Bitwarden) at deploy time.
diff --git a/.agents/skills/building-carlos-apps/references/decisions.md b/.agents/skills/building-carlos-apps/references/decisions.md
new file mode 100644
index 0000000..1372a65
--- /dev/null
+++ b/.agents/skills/building-carlos-apps/references/decisions.md
@@ -0,0 +1,211 @@
+# πŸ€– The decisions β€” trust, shape, hosting, identity
+
+The four axes every CARLOS app settles on day one. 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:**
+
+- **E2EE content, plaintext routing metadata β€” Keymail.** 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.** 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.** 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 β€” Seapointish.** 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.** 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,
+ Seapointish, 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 (the edge routes by Host only), 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. Pay per app (baseline + instance-hours; hibernating apps cost
+ ~the baseline; egress/storage metered separately). Free rungs exist
+ (a permanent tiny Try tier; a budget-funded free pool that degrades
+ generosity, never availability). Nothing about the other axes locks
+ you out of moving later.
+- **Customer fleets β€” your boxes, the hosted control plane.** `carlos
+ fleets` attaches boxes you own (a Pi, an office server, edge devices)
+ to your account; apps place onto them with `carlos 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 `carlos` CLI 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_id` is 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.
diff --git a/.agents/skills/building-carlos-apps/references/platform.md b/.agents/skills/building-carlos-apps/references/platform.md
new file mode 100644
index 0000000..84deab1
--- /dev/null
+++ b/.agents/skills/building-carlos-apps/references/platform.md
@@ -0,0 +1,459 @@
+# πŸ€– The CARLOS platform, member-side
+
+What `carlosframework/platform` does for an app, and the `carlos` CLI
+surface a member drives it with. Everything here works with **zero
+infrastructure access** β€” no SSH, no AWS console, no box commands. That is
+a design law, not a convenience: every command is built for a user who has
+nothing but the CLI and a browser. If a task seems to need a box, either
+you are self-hosting and operating the platform itself, or you have found
+a product gap to file β€” never a workaround to build.
+
+Snapshot date 2026-09-02; verbs are stable, flag details evolve β€” trust
+`carlos <verb> -h` over this file. The CLI ships for macOS/Linux (brew,
+apt, static binaries) and Windows (client-only zip β€” no self-replace,
+`carlos update` defers to a fresh download).
+
+## Start with `carlos auth whoami`
+
+Before concluding you cannot reach a CARLOS deployment, run it:
+
+```
+carlos auth whoami # the default console
+carlos auth whoami --console https://<console> # a specific one
+```
+
+It prints the identity, the terminal's label, the expiry, and every
+account with its sqid β€” which is also where you find the sqid your app's
+hostname needs.
+
+This is first because the obvious check is the misleading one. An agent's
+instinct is to `curl` the console and read the answer: the console will
+`302` you to a login page every time, because a browser session is not
+what the CLI holds. That redirect says nothing about whether you have
+access, and reading it as "no access" has already cost one session an
+afternoon and ended in a filed issue. **The CLI's credential is the only
+thing that answers the question.**
+
+Same for the deployment's identity: a self-hosted console is not
+`carloku.com`, and the console origin and the apps domain are different
+hostnames (Tito's are `carlos.tito.io` and `platform.tito.io`). If a
+project commits a `.carlos/config`, that names the console it belongs to;
+otherwise `--console` does.
+
+## What the platform owns (never hand-roll these)
+
+- **Routing and TLS** β€” the edge is the only process on :443; the route
+ table doubles as the ACME allowlist; per-host certs auto-obtained and
+ renewed, including customer domains and (via ACME delegation) wildcards.
+- **Replication** β€” Litestream on every instance database, run by the
+ platform's host agent; restore drills are the platform's job too.
+- **Hibernation** β€” provisioned instances doze when idle and wake on
+ request; live and default-on, not a future feature. Cents-per-month
+ idle cost is the platform's economic story.
+- **Process supervision and restarts** β€” instances are converged from
+ bucket records; `carlos restart` cycles them in seconds, console-side.
+- **Config delivery** β€” `carlos env` / `carlos secrets` writes converge
+ onto boxes within seconds (a serial bump, 2s poll). No ssh-delivered
+ EnvironmentFiles.
+- **Deploy verification** β€” the edge stamps `X-Carlos-Version` per
+ versioned route (only after the instance actually restarted);
+ `carlos deploy` watches it.
+- **Outbound email** β€” the platform mints a sending identity on a domain,
+ publishes DKIM/SPF/DMARC where it controls the zone, and delivers working
+ SMTP credentials as config. Apps never hold an AWS key and nobody runs an
+ MTA. See "Sending email" below.
+- **Scheduled work** β€” the platform keeps the clock: a declared schedule
+ POSTs a tick to a path in your app at the time it is due, waking a
+ hibernating instance to run it. No in-app scheduler, no cron line on a
+ box. See "Scheduled work" below.
+
+## Concepts
+
+- **Account** β€” the tenancy unit. Public short id (**sqid**, e.g. three
+ letters) appears in default hostnames; it is not a secret. Roles are
+ owner/member. `carlos auth whoami` shows yours.
+- **App** β€” named per account (not globally). Claimed with
+ `carlos apps create`; deleted apps sit in a 30-day trash.
+- **Release** β€” immutable, content-addressed, produced by the ship half
+ of `carlos deploy` (or by `carlos ship` on its own).
+ Kinds: `binary` (default) and `static`. Versions are free-form; git
+ short sha is the house convention.
+- **Channels and pipelines** (release pipelines v2, live 2026-08-22) β€”
+ **a new app is born with one channel, named `edge` by default**
+ (renameable at creation): that is its production, and `carlos deploy`
+ lands on it. Multi-channel ceremony is opt-in: a console-mediated
+ **pipeline** declares an ordered channel list (names are app-defined;
+ promotion onto channel N must come from Nβˆ’1; the first is the entry
+ channel) with per-channel, default-permissive policy β€” `bake`
+ duration, `passkey` step-up, `promote_approvals`, and
+ `change_approvals` (which also guards editing/removing the channel
+ and fast-tracking through its bake). `canary/<slug>` stays a reserved
+ platform namespace outside any pipeline: always allowed, zero bake,
+ per-session dead ends. Apps with no pipeline keep the legacy frozen
+ ladder (`edge β†’ beta β†’ stable`, holds 0/24h/72h, unconditional
+ passkey on stable β€” reaching stable cuts a semver tag; `-hotfix`
+ bypasses, recorded). Box-side, bake changes **ratchet**: a shorter
+ window is honoured only after the previously-known window has elapsed
+ once on the box's own clock, so a compromised console session cannot
+ collapse a hold and ship in the same hour.
+- **Production flag** β€” legacy: superseded by per-channel pipeline rules
+ for pipelined apps, still honoured by legacy boxes/apps. Its sharp
+ edge is recorded: a stable promote plus the console's default-checked
+ safety delay once left a hibernating app unwakeable for days β€”
+ ceremony belongs on channels you chose, not on defaults.
+- **Instance** β€” one account's running process for an app on a host.
+ Declared console-side (`carlos instances enable` once per app, then
+ `instances create -host …`); a box reconciler mints the actual route.
+ Exec-backed and hibernating by default. The process contract is
+ `<bin> --socket <path> --db <path>` on a unix socket β€” there is no
+ `$PORT` β€” and every instance serves `GET /healthz` and
+ `GET /api/version`. **A static site has no instance** β€” the edge serves
+ it off the channel pointer, and giving one an instance produces a route
+ that can never wake ("Deploying a static site" below).
+- **`.carlos/config`** β€” two layers, global `~/.carlos/config` and
+ per-project `./.carlos/config` (committed; nearest wins walking up).
+ Holds console, account, app, kind, artifact β€” the reason zero-argument
+ `carlos deploy` works. Project values are honored only when the file's
+ console matches the session's, so a committed config cannot silently
+ redirect someone else's credentials.
+- **Default addresses** β€” `<app>.<sqid>.<apps-domain>` (on the hosted
+ platform, `oncarlos.com`); canary form `<canary>.<app>.<sqid>.<domain>`.
+ Minted **alias** hosts intentionally carry no `X-Carlos-Version` β€”
+ verify on the canonical host.
+
+## The member CLI
+
+| Verb | What it does |
+|---|---|
+| `carlos auth login\|whoami\|logout\|default` | Device-code login (approve in any signed-in browser); identity + memberships; per-project default console |
+| `carlos apps create\|place\|delete\|restore` | Claim an app; place it on a customer fleet; trash/restore |
+| `carlos deploy` | **Release new code β€” reach for this one.** ship + promote + watch `X-Carlos-Version` until live; zero-arg with a saved project config. `-channel` to land somewhere other than the entry channel (a canary). Also the WHOLE story for a static site: `-kind static -host <h> <dir>` declares the route as well as shipping it ("Deploying a static site") |
+| `carlos ship` | The ship half alone: publish a release *without* releasing it (`-kind binary\|static`, `-version`, `-notes`); rate-limited per app (~2/minute β€” a 429 carries `Retry-After`) |
+| `carlos promote` | The promote half alone: move an **already-shipped** version onto a channel (`-hotfix` to bypass a bake, recorded) β€” a ladder step or a re-point, not how you release new code. The channel is positional, and optional when the app has only one |
+| `carlos rollback` | Point a channel back at an earlier version |
+| `carlos pipeline` | Show or shape the app's release channels; `init -template edge-production\|full-ladder` replaces the single default channel with a starter pipeline |
+| `carlos channels` / `carlos releases` | What each channel serves / every shipped version; `releases retention` prunes old ones |
+| `carlos version target` | The semver family ships auto-increment under |
+| `carlos env` / `carlos secrets` | Plain vars / sealed secrets, layered per environment; `env sync` forces convergence; `secrets genkey` mints keypairs locally |
+| `carlos instances enable\|create\|list\|delete\|set-upstreams` | Opt an app in; declare/inspect/remove instances; repoint upstreams. For **binaries only** β€” a static site needs none of these, and `carlos deploy` declares what it does need |
+| `carlos restart` | Cycle an app's processes β€” no version or config change |
+| `carlos logs` | Merged app + platform + edge timeline (`-f` follows, `-grep`, `-since`) β€” no box access |
+| `carlos domains attach\|detach\|list` | Claim customer hostnames (`-wildcard`, `-catchall`); prints the DNS records to create; certs follow automatically |
+| `carlos store create\|status\|rotate` | Declare object storage; credentials arrive as env; member-driven key rotation |
+| `carlos email enable\|status\|test\|domains\|credentials\|rotate` | Declare sending; provision a verified domain; SMTP credentials arrive as env (`pause`/`resume` are a deployment operator's) |
+| `carlos schedule ls\|set\|rm\|run` | Declare recurring work per app (`-every 6h` or `-cron "0 8 * * *"` β†’ a `POST` to a path your app serves); `ls` shows next/last per instance; `run` fires one now |
+| `carlos ledger append\|publish\|verify` | Open hash-chained per-app ledgers (the transparency machinery) |
+| `carlos accounts create\|list\|migrate` | Mint/list accounts; move an app between them |
+| `carlos fleets create\|add-box\|rotate-token\|…` | Bring-your-own-boxes fleets that dial the console |
+| `carlos update` | Update the CLI binary itself (signature-verified; defers to brew/apt) |
+| `carlos vet` | Check a release against the platform contract |
+
+Box-side verbs exist (`edge`, `agent`, `adopt`, `route`, `add`, `ops`,
+`bootstrap`) but they are the *operator's* surface for running a platform
+deployment β€” a member never types them, and an agent reaching for them on
+a member task has taken a wrong turn.
+
+## Deploying a static site
+
+A static site is one command, and the command is the same one binaries use:
+
+```
+carlos apps create --app mysite
+carlos deploy --app mysite --kind static --host mysite.<sqid>.oncarlos.com ./site
+```
+
+That ships the directory as an immutable release, promotes it to the app's
+entry channel, declares the route, and watches the URL until
+`X-Carlos-Version` reports the build. Nothing else is needed β€” not
+`carlos instances enable`, not `carlos instances create`, not a separate
+`carlos domains attach`.
+
+`--host` is required on the **first** static deploy and only that one. A
+static app has no instance record yet, so there is nothing to resolve the
+host from; that deploy writes the record, and afterwards plain `carlos
+deploy` resolves host and channel by itself. Give it a custom domain and
+the deploy attaches that too, printing the DNS records to publish.
+
+Expect the URL to take up to a minute after `promoted` prints. A static
+route trusts its resolved channel pointer for about 60 seconds, so the old
+page can outlive the promote by that long β€” `carlos deploy` already waits
+past one such window, and says so while it waits.
+
+### A static site has no instance, and must not be given one
+
+**This is the trap.** The verbs read like the binary recipe β€” enable an
+app for instances, create one, point it at a channel β€” and every step
+succeeds. The result cannot work.
+
+A static site is served by the edge straight off the channel pointer:
+there is no process, nothing hibernates, nothing wakes. An **instance** is
+a process the platform execs. A static release's manifest carries a site
+tarball and no build for any box's architecture, so an instance route on
+that channel finds nothing to run and fails every wake, permanently.
+
+Older platform builds reported that failure as *"nothing is promoted for
+this instance's channel"* β€” while `carlos channels` showed the promote
+sitting right there. Both statements were true and neither named the
+problem, which is the shape of the route, not the state of the channel.
+Current builds name it and refuse the create; if you meet the old message,
+this is what it means.
+
+The way out, on an app already in that state:
+
+```
+carlos instances delete --app mysite --host mysite.<sqid>.oncarlos.com
+carlos instances create --app mysite --host mysite.<sqid>.oncarlos.com --kind static --channel <ch>
+```
+
+The already-shipped release needs no re-ship β€” a static route reads the
+channel pointer, so the site serves within seconds of the record landing.
+
+### Channels belong to the app, not to the platform
+
+A new app is born with **one** channel, `edge` by default, and that one
+channel is its production. More are opt-in, declared by a pipeline, and
+named whatever the app calls them.
+
+So do not guess a channel name from the `canary β†’ edge β†’ beta β†’ stable`
+ladder in `--help`: that is the frozen legacy ladder an app inherits only
+when it declares no pipeline. `carlos channels` lists what this app
+actually has and `carlos pipeline` shows the order. `carlos promote
+<version>` with no channel resolves it when the app has one, and names the
+app's real channels when it has several.
+
+## Sending email
+
+The platform issues SMTP credentials. An app declares that it sends, the
+console provisions an SES identity for a domain, publishes the DNS records
+where it controls the zone, and delivers working credentials as config. The
+app never holds a cloud key and nobody runs an MTA.
+
+One command does the whole walk β€” declare, provision the domain, wait for
+verification, deliver:
+
+```
+carlos email enable -app <app>
+```
+
+The identity defaults to the app's **own host**
+(`<app>.<sqid>.oncarlos.com`), so the signing domain matches the domain of
+the links inside the mail. Credentials arrive as env β€”
+`CARLOS_SMTP_HOST`, `_PORT`, `_USER`, `_PASS`, `_FROM`. Transport is
+submission on 587 with STARTTLS. `-env-prefix MAILER` renames the whole set
+to `MAILER_HOST`, `MAILER_PORT`, and so on, for an app that already reads
+its own names.
+
+For a customer's own domain:
+
+```
+carlos email enable -app <app> -domain mail.example.com
+```
+
+On the platform domain CARLOS publishes the records itself and verification
+is usually seconds. On a custom domain it prints the record set for the
+zone's owner to publish, then polls until SES confirms it (`-timeout`,
+default 10m; a gave-up wait exits non-zero and names what SES is still
+waiting for). `carlos email domains add` is the same provisioning step on
+its own, for adding a second sending domain to an app that already sends.
+
+The record set a customer has to publish is always these five, for
+`<domain>` and the region the identity was first verified in:
+
+| Name | Type | Value |
+|---|---|---|
+| `<token>._domainkey.<domain>` Γ— 3 | CNAME | `<token>.dkim.amazonses.com` |
+| `mail-<region>.<domain>` | MX | `10 feedback-smtp.<region>.amazonses.com` |
+| `mail-<region>.<domain>` | TXT | `v=spf1 include:amazonses.com ~all` |
+| `_dmarc.<domain>` | TXT | `v=DMARC1; p=none; adkim=s; aspf=s` (plus `rua=` if the deployment sets one) |
+
+The SPF record sits on the MAIL FROM subdomain rather than the identity
+domain because that is the domain SPF is checked against.
+
+**The published DMARC policy is `p=none`**, deliberately: a tenant whose
+very first send is rejected by its own DMARC record learns nothing useful
+from the failure. There is no `carlos email` verb that changes it. On a
+custom domain the zone is the customer's, so they can tighten it themselves
+whenever they choose β€” read the DKIM note below first.
+
+Then:
+
+| Verb | What it does |
+|---|---|
+| `carlos email status -app <app>` | What is declared, which domains verified, what was delivered |
+| `carlos email test -app <app> -to me@example.com` | Sends a REAL message through a throwaway credential, then revokes it |
+| `carlos email credentials create\|list\|revoke` | Standalone credentials for something not running on CARLOS; the password is shown **once** |
+| `carlos email rotate -app <app> [-finish]` | Two-phase key rotation β€” new key delivered, then the old one retired |
+
+### Three things that will bite
+
+- **A freshly minted credential does not work for the first minute or
+ two.** SES's SMTP endpoint does not see a new IAM access key
+ immediately, and the symptom is `535 Authentication Credentials Invalid`
+ β€” which reads exactly like a wrong password and is not one. Wait and
+ retry. An app's first sends straight after `enable` hit this too, so do
+ not diagnose a broken credential from the first minute of logs.
+ **A 535 that outlasts a few minutes is a different problem** and is worth
+ treating as real: check the key is still `Active` and not paused
+ (`carlos email status`), and that the password was derived for the same
+ region as the SMTP host β€” the derivation is region-scoped, so a
+ credential minted for one region never authenticates against another's
+ endpoint.
+- **DMARC alignment rests on DKIM alone.** Records are published with
+ `adkim=s; aspf=s`, and that strictness is what stops one tenant's
+ signature authenticating another tenant's From address on a shared
+ domain. SPF authenticates but does **not** align: the envelope sender is
+ a region-qualified subdomain (`mail-<region>.<domain>`), which strict
+ alignment treats as a different domain. DMARC therefore passes on the
+ DKIM rail only. That is normal for SES β€” but it means DKIM is the single
+ rail, so never turn off DKIM signing on a sending identity. If a customer
+ tightens their own zone to `p=reject`, that is safe but unforgiving: a
+ DKIM CNAME later dropped from their zone stops being a downgrade and
+ becomes total delivery failure.
+- **The MAIL FROM subdomain carries its region forever.** It is fixed at
+ the first verification (`mail-eu-west-1.<domain>`). On a custom domain
+ that record lives in the customer's zone β€” the one record CARLOS cannot
+ republish for them β€” so moving regions later means going back to the
+ customer for a DNS change.
+
+## Scheduled work
+
+The platform keeps the clock. A hibernating instance cannot run an
+in-process timer, so recurring work is declared from outside the app and
+delivered as a **tick**: a `POST` to a path you declared, at the time it
+is due, waking the instance first and holding it awake while your handler
+runs. Nothing else crosses the boundary β€” no payload, no job state, no
+queue. Live 2026-08-24.
+
+Declare it with the CLI, not in code:
+
+```
+carlos schedule set -app <app> -name sync -every 6h -path /jobs/sync
+carlos schedule set -app <app> -name reminders -cron "0 8 * * *" -path /jobs/reminders
+```
+
+`-every` is a Go duration, whole minutes, 1m to 30d. `-cron` is five UTC
+fields (`min hour dom month dow`, with `*`, `n`, `a-b`, `*/n`, `a,b`) β€”
+no names, no `@daily`, no seconds. Names are
+`^[a-z0-9][a-z0-9-]{0,31}$`, at most 20 per app. `carlos schedule ls`
+prints the declaration and then what each instance reports (`next`,
+`last`, `last_status`); `run` requests an out-of-band fire on top of the
+normal rhythm; `rm` removes the declaration. The verbs say **recorded**
+and **requested**, never "fired": they write one console object, and each
+box acts on it within seconds.
+
+The app's whole part is a handler on that path:
+
+```go
+// POST /jobs/sync
+func handleSync(w http.ResponseWriter, r *http.Request) {
+ if !carlos.Tick(r) { // github.com/carlosframework/rastrillo/carlos
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ if occ, ok := carlos.TickOccurrence(r); ok && alreadyDone(occ) {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ if err := syncer.RunOnce(r.Context()); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError) // 5xx = retry
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+```
+
+`carlos.Tick` is the constant-time compare of the bearer against
+`$CARLOS_ADMIN_TOKEN`, which the platform mints into the instance's
+environment; use it rather than hand-rolling the check. A tick arrives on
+an ordinary public route, so a tick nobody authenticated is an internet
+request to that path β€” and the `X-Carlos-*` headers are not evidence,
+anyone can set those. The status is your whole reply: **2xx** done,
+**5xx** retry with backoff (five attempts, 1m doubling to 15m, then the
+schedule advances), **4xx** don't bother.
+
+One-shots are the app's own call, over the control socket the agent binds
+before your process starts:
+
+```go
+err := carlos.ScheduleAt(ctx, "remind-"+id, when, "/jobs/remind") // carlos.ScheduleCancel drops it
+```
+
+Same tick, same handler, same name namespace as declared schedules (a
+collision returns `ErrDeclaredSchedule`). Up to 400 days ahead, 1000
+pending per host. Reach for one only when the work genuinely has a
+specific time; a daily schedule whose handler asks its own database
+"what falls today?" beats one timer per row.
+
+### Things that bite
+
+- **Do the work inside the request.** The instance is held awake for
+ exactly as long as the request is open (30-minute ceiling) and the idle
+ clock starts the moment you return. Reply 202 and finish in a goroutine
+ and that goroutine gets hibernated mid-job.
+- **Dedupe on `X-Carlos-Schedule-At`** β€” unix seconds, read it with
+ `carlos.TickOccurrence`. It is the instant the delivery is *for*, so
+ every retry of one failed occurrence carries the same value, as does a
+ redelivery after a box crash or a deploy that cut a long job. Never key
+ on the wall clock: a retry twenty minutes later is the same occurrence.
+ Keep `Tick` as the guard β€” a request with no occurrence header means
+ "no dedupe key, run it", which is what your own "Sync now" button looks
+ like.
+- **A 4xx is never retried.** A path with no handler, or a handler that
+ refuses a token it cannot see, records `app refused` and the schedule
+ advances to its next occurrence. That failure is quiet β€” `next` still
+ moves, so `ls` reads like a success. Check `last_status`.
+- **An instance running since before the platform roll refuses its own
+ ticks.** The token is minted at spawn, so a process older than the roll
+ has no `$CARLOS_ADMIN_TOKEN`, `Tick` is always false, and
+ `carlos.ScheduleAt` returns `ErrUnauthorized`. `carlos restart` clears
+ it; nothing else does.
+- **Unit-backed instances are not delivered to** in this release β€” `ls`
+ says `unsupported` against them. Exec-backed instances (the default)
+ and sidecars work.
+- **Once-timers outlive the process.** They live in the box registry, so
+ a restart loses none, and re-asserting them at boot is safe because a
+ repeated name replaces rather than adds. Off-platform there is no
+ control socket at all: `ScheduleAt` returns `ErrNotOnCarlos`, which
+ boot code should treat as "skip", not as a failure.
+
+## Deploy truths (each paid for at least once)
+
+- **`carlos deploy` is the release motion**: ship, promote to the entry
+ channel (or the channel the app's instances follow), then watch the URL
+ until the header reports the shipped build. "Held for bake" on a
+ channel that declares one is the system working, not failing.
+- **A promote is not a deploy** until the process cycles. The platform
+ restarts unit-stamped routes and wakes hibernating tenants into the new
+ build (a session in flight keeps the old binary until its instance
+ idles β€” that is the bake, not a failure). A bespoke long-running unit
+ outside the platform's knowledge stays old until *something* restarts
+ it β€” know which of the three your app is.
+- **Verify against the thing you changed, with the binary you built.**
+ A 200 is not proof: an app-shell route returns 200 HTML for
+ `/api/version` and will happily "verify" any build ever shipped. The
+ `X-Carlos-Version` header on the canonical host is the proof; stale
+ DNS and stale local binaries have both produced false "verified"
+ reports.
+- **When CI ships for you, verify by ancestry, not equality** β€” a PR
+ merging behind yours cancels your run and ships a commit *containing*
+ yours.
+- **Stamp your build version.** For rastrillo apps,
+ `-ldflags "-X github.com/carlosframework/rastrillo.BuildVersion=<sha>"`
+ β€” or every release's `/api/version` reports `dev`.
+
+## Self-hosting the platform
+
+The platform is the same software members deploy onto: a self-hosted
+deployment (Tito's path) runs its own console, boxes, and bucket in its
+own cloud account, and its members use the identical `carlos` CLI pointed
+at that console. Operating it is real work β€” box provisioning, the
+adopt/restart cadence, platform rolls β€” and it is the one context where
+box verbs and cloud access are legitimate. Commit a `.carlos/config`
+naming the deployment's console so sessions cannot fall back to the wrong
+one; the CLI's account default is not your account (`ops` exists on every
+deployment and is empty β€” a `not found` against it once cost an hour of
+confident wrong diagnosis).
+
+For the underlying machinery β€” registry, router, replication, hibernation
+internals β€” see blueprint.md, which is the reference for what the
+platform does on your behalf and for hand-rolling outside it.
diff --git a/.agents/skills/building-carlos-apps/references/process.md b/.agents/skills/building-carlos-apps/references/process.md
new file mode 100644
index 0000000..c15c623
--- /dev/null
+++ b/.agents/skills/building-carlos-apps/references/process.md
@@ -0,0 +1,190 @@
+# πŸ€– CARLOS working conventions
+
+How the family builds. These conventions exist because each was learned the
+hard way at least once; the incident is noted where it names the rule.
+
+## Repo documents
+
+- **AGENTS.md** β€” "how we build and the decisions we've committed to."
+ Written before the code (factor V: the intent is the system β€” the code
+ should have to argue with something). Opens with the one rule. Contains:
+ settled decisions (dated, attributed, with rejected alternatives),
+ named laws, the workflow, and a "Deliberately not built yet" list with the
+ trigger for each deferral. One file, for every agent that reads the repo β€”
+ not one per vendor. Older repos still carry it as CLAUDE.md; rename it to
+ AGENTS.md the next time you touch one, rather than keeping two.
+- **README.md** β€” user-facing: what it is, run it, honest trade-offs.
+- **DESIGN.md** β€” the spec, what gets built. **PLAN.md** β€” why it is shaped
+ this way, the roadmap.
+- **docs/** β€” design records and briefs, written *before* the build
+ ("Brainstormed with Paul; decisions recorded here"), each with a Status
+ line (`built` / `approved design β€” not yet built`), annotated in place
+ when built. The doc's decisions are binding: if one proves wrong in
+ practice, stop and flag it in the PR rather than redesigning.
+
+## Git and PR playbook
+
+- **Worktree per session, always.** Multiple sessions (agents and humans)
+ are active at once; the main checkout's uncommitted state belongs to
+ another session. Never build in the main checkout β€” reading it is fine,
+ writing is not.
+- **The cadence is: worktree β†’ branch β†’ regular pushed commits β†’
+ squash-merge.** Commit as you go and push every commit. The branch is
+ where work in progress lives; you do not wait to be asked to put it
+ there, and a session that ends with unpushed work has lost it as far as
+ everyone else is concerned. Keep the PR small and focused.
+- **Squash-merge to main when all three hold: the work is done, a human has
+ approved it, and CI is green** (`gh pr merge --squash --delete-branch`).
+ Two of the three is not a merge β€” a green run on unapproved work waits,
+ and an approval over a red run waits. Never commit to main. Deploy only
+ merged main, and only when asked.
+- **"CI green" binds once CI exists, and a new repo's early work is to
+ make it exist.** A repo with no CI has no green to wait for: the
+ condition is not waived, it simply has nothing to read yet, so the first
+ merges of a new project land on done + approved. Standing up CI is one
+ of the first work items, not a prerequisite nobody can satisfy β€” and it
+ is the gate you already run locally, kept as one definition rather than
+ a second list that drifts, so debugging CI is running it. Once the repo
+ has CI all three bind absolutely and the carve-out is spent.
+- Commit subjects are area-prefixed prose that says why (`web: the front
+ door is a homepage β€” pitch left, sign-in right`; `auth: delete the
+ recovery passphrase, root and branch`). Comment the why, not the what.
+- Every commit carries `Co-Authored-By: Claude <model>
+ <noreply@anthropic.com>`; the human is the author of record.
+
+## Canaries
+
+- **Every session gets its own canary; review happens on a deployed canary,
+ never on localhost.** (Adopted after two sessions sharing a dev host
+ produced an evening outage.) Local stacks are for automated drives only.
+- On the platform, a canary is a channel: `carlos deploy -channel
+ canary/<slug>` (a per-session dead-end rung, zero bake) and the canary
+ host serves it at
+ `<canary>.<app>.<sqid>.<domain>` β€” same rule, no box. Off-platform,
+ canaries multiplex onto the devbox: own port/state/unit/TLS at
+ `<branch>.<dev-host>`. Shared hosts (`prod`, the plain dev host) only ever
+ carry main builds β€” enforced by the deploy path, not memory.
+- Report the complete clickable canary URL in **every** reply while
+ iterating, with a short list of what to test. The human clicks through
+ mid-flight; never make them scroll back for the URL.
+- Tear your canary down when the branch merges. Never echo claim links or
+ secrets into the session β€” the prompt ledger publishes what gets typed.
+
+## Testing
+
+The layers, in order:
+
+1. **`go test ./...`, `go vet ./...`, `gofmt -l .` β€” all clean, always.**
+ Nothing lands untested; a bug fix carries its regression test. When
+ gating on a test run, never pipe it β€” a pipe eats the exit code (a red
+ suite once merged and deployed exactly this way). A guard belongs
+ *inside* the test binary, never in a shell step around it β€” a guard a
+ shell step can satisfy is a guard a shell step can be wrong about
+ (one browser-required check went silently unrun for weeks).
+2. **Prefer a parsing test to a browser for anything computable from
+ source.** Contrast ratios computed from the token file's declarations
+ (WCAG AA, both themes, no browser); network reachability rules parsed
+ from the served JS; performance fixes shipped with an `EXPLAIN QUERY
+ PLAN` assertion over the *exported production query string* so the
+ test cannot drift from the real SQL. When a rule needs an exception,
+ the allowlist entry pins the exact occurrence count, not just the
+ file β€” a new call site inside an exempted file must still fail.
+3. **JS logic DOM-free under `node --test`**, wired into `go test` via a
+ small `jstest_test.go`, so one command runs everything. `node --check`
+ alone is not enough β€” it parses as CommonJS and swallows ES-module errors
+ that crash Safari; check a `.mjs` copy and load it in a real engine.
+4. **uidump β€” dump, don't drive** (the seapointish pattern, adopted by
+ the platform console). An env-gated test (`UIDUMP_DIR` set, else
+ skipped) boots the real handler in-process over `httptest`, seeds a
+ fixture world rich enough that every state has a subject, signs in,
+ GETs every named screen, and writes each response body to disk with
+ asset paths rewritten relative so the files stand alone. An agent
+ then screenshots the static files directly with headless Chrome
+ (`--headless --screenshot=…`), re-shooting dark mode by stamping the
+ theme attribute and mobile at a narrow window. Near-zero cost, zero
+ flake, works on a display-less box; adding a screen is one map entry
+ β€” and **"add your screen to the dump" is a checklist line in every
+ plan**, or coverage rots. It renders HTML+CSS only and asserts
+ nothing itself: it is the visual-review gate, not the invariant gate.
+5. **Browser drives are for the residue** that needs a JS engine and
+ real layout geometry β€” enhancement-script behaviour, popover sizing,
+ overflow, theme resolution β€” env-gated, with assertions written as
+ the named bug class they caught. Playwright, or chromedp for
+ pure-Go. Passkey flows run in Chromium β€” the virtual authenticator
+ is a CDP feature; a separate passkey-free smoke runs in WebKit,
+ Safari's engine, because it breaks first. Loud on purpose: fail on
+ any console error, page error, or failed request, and junk-scan
+ every view for `undefined`, `null`, `[object Object]`, `NaN` β€” the
+ bug class that renders perfectly and says nothing (a stray `null`
+ *text node* included: native `append()` stringifies one). A skip is
+ not a pass: report executed-vs-skipped.
+6. **The merciless greps** (server-blind apps): end-to-end tests that grep
+ raw SQLite bytes *and* S3 objects for plaintext content and names.
+7. **After every deploy: the console check in WebKit** against the live
+ host (the keymail rule) β€” a JS syntax error takes the whole client down
+ and only a real engine sees it; WebKit specifically, because it is the
+ strictest engine and Safari users hit it first.
+8. **Security scan** (Aikido) before ending a work cycle; triage every
+ finding; never publish over an untriaged one.
+
+Simulation suites are the spec for convergence-critical engines (sync,
+progression): any behaviour change changes the suite first. Golden vectors
+pin multi-language implementations (see blueprint.md).
+
+## Ending a session
+
+Every session ends through the committed `/end-session` gate. The shape:
+
+1. Clean tree β€” nothing uncommitted, nothing unpushed, no orphan worktrees.
+2. Gates green: build, vet, gofmt, tests (unpiped), browser drives with
+ executed-vs-skipped counts.
+3. Security scan clean or triaged.
+4. Ledgers accrued (below), reviewed word-by-word for secrets before
+ publishing β€” never publish text you haven't read; when unsure, redact
+ with an annotated `⟦redacted: category⟧` and say what and why.
+5. Deploy state reported; canaries torn down or handed over.
+6. A handoff report: PRs, clickable canary URLs with what to verify, open
+ threads, and what was deliberately not done.
+
+## Ledgers and transparency
+
+The family practices radical operational transparency on a public status
+site: **prompt registry** (every human prompt behind the code, whole and in
+order, credentials redacted in place), **carbon tally** (token spend β†’
+COβ‚‚e bands, plus the boxes' share), **contributors** (humans and AI models
+on one metric, from git trailers), roadmap and milestones written in member
+language β€” what changed for a member, never how the boxes are run. Ledger
+updates ship exactly like code: commit β†’ PR β†’ deploy. No hostnames, no
+account IDs, no third-party personal data in anything published.
+
+## AI authorship (factor X)
+
+- Machine-authored text is marked **πŸ€–** wherever it surfaces β€” visible,
+ immediately before the heading or block it covers; a marker on a heading
+ cascades. Never hide the πŸ€– in a comment.
+- A person marker (πŸ‘¨/πŸ‘€/πŸ§‘) certifies human text: an LLM must not rewrite,
+ rephrase, condense, or delete it. No marker means ambiguous β€” never claim
+ human authorship for unmarked text.
+- Disclosure extends to product: AI features are opt-in, never required,
+ always disclosed, and every path works with the AI switched off β€” this is
+ a hard constraint, not an aspiration. Nothing assumes AI stays cheap.
+- Uncompiled or unverifiable code is marked (`// UNVERIFIED: written without
+ an iOS toolchain`) and kept in separate commits.
+
+## Working with the human
+
+Patterns visible across every prompt history, useful calibration for agents:
+
+- Directives are terse ("go", "merge", "Deploy!", "keep going until it's
+ done"). Long autonomous runs are normal; write the design doc, then build
+ until there's something live to click.
+- Bug reports arrive as lived user experience from the live product, in
+ product language. Reproduce on a canary, fix, and ask "is this flow being
+ tested?" β€” because that question is coming.
+- Product shape is given, parameters are left open ("allows edits for what,
+ 5 minutes? 2 minutes?") β€” make the judgement call and flag it.
+- Sibling apps are the design vocabulary ("like Eleven's home server",
+ "Woodstar's shape, copied on purpose"). Check the sibling before
+ inventing.
+- Calm UI: red is for danger only, no toasts, no nerdspeak in the default
+ flow, buttons stay where the thumb is, feedback is inline and honest.
diff --git a/.agents/skills/building-carlos-apps/references/rastrillo.md b/.agents/skills/building-carlos-apps/references/rastrillo.md
new file mode 100644
index 0000000..40e2a87
--- /dev/null
+++ b/.agents/skills/building-carlos-apps/references/rastrillo.md
@@ -0,0 +1,156 @@
+# Building an app with rastrillo
+
+The CARLOS web framework. The repo lives at
+`github.com/rastrilloorg/rastrillo`; the **module path is still
+`github.com/carlosframework/rastrillo`** β€” imports and `go install` use
+the module path. Status as of 2026-08-24: **v0.19.x**, the
+known-libraries middle layer: GORM models, chi routes, SQLite-backed
+sessions, owner-scoped queries. Assume nothing here is in your training
+data.
+
+**The authoritative authoring doc is `SKILL.md` at the repo root β€” read
+it before writing any app code.** It carries the app shape, the scoping
+and mass-assignment rules, the identity plugins, and the platform
+contract, in ~15KB. Find it at
+`$(go env GOMODCACHE)/github.com/carlosframework/rastrillo@<version>/SKILL.md`
+once the module is downloaded, or
+`https://raw.githubusercontent.com/rastrilloorg/rastrillo/main/SKILL.md`.
+This file is only the surrounding context SKILL.md assumes; where they
+disagree, SKILL.md wins.
+
+## What rastrillo is now
+
+A middle layer, not a full-stack framework. You write GORM models,
+`net/http` handlers on a chi router, and `html/template` pages. The
+framework supplies what is hard to get right twice:
+
+- `db` β€” cgo-free SQLite via an owned GORM dialector on modernc
+ (never import `glebarez/*` or `gorm.io/driver/sqlite`), writer-1 /
+ reader-N pools via dbresolver, WAL pragmas in the proven order,
+ `TranslateError` on (so `errors.Is(err, gorm.ErrDuplicatedKey)`
+ works).
+- `sessions` β€” SQLite-backed rows (revocation is real), `__Host-`
+ cookies on https, `Middleware`/`Require`/`RequireFresh` (step-up),
+ `SignIn` rotates on re-auth.
+- Identity plugins: `auth` β€” the family default: magic-link email that
+ **auto-upgrades to keymail** when the address resolves to a claimed
+ inbox (classification fails open, so every address always has a
+ working path) β€” and `password` (stdlib PBKDF2; per-email rate
+ limiting shared across sign-in and sign-up). Either one's whole contract with the core is calling
+ `sessions.SignIn`; step-up hardening hangs on
+ `sessions.RequireFresh`.
+- `csrf` (origin-checking, not tokens), `flash`, `form`, `view`,
+ `scope` (`scope.Owned` β€” owner scoping with the 404-not-403
+ contract).
+- `crypto` (+ JS twin), `webauthn`, `blobs`, `eventlog`, `mail`, `ui` β€”
+ the golden-vectored satellite libraries.
+- The platform layer: `Resolve`/`Serve`/`Run` speak CARLOS activation
+ (argv shapes, `LISTEN_FDS`, `$STATE_DIRECTORY`, `/healthz`,
+ `/api/version`, SIGTERM drain) so the app doesn't β€” and Serve sets
+ baseline security headers (CSP, nosniff, frame-deny, referrer
+ policy) on every response; an app's own `Set` wins, `Options.CSP`
+ swaps the policy.
+
+## The ten-minute path
+
+```sh
+go install github.com/carlosframework/rastrillo/cmd/rastrillo@latest
+rastrillo new myapp && cd myapp && go mod tidy && go test ./...
+# the scaffold is SKILL.md's five-file shape, tests passing out of
+# the box: internal/myapp/{models,app,handlers,render}.go, cmd/myapp/
+CGO_ENABLED=0 go build ./... && ./myapp -addr :8080
+```
+
+Verify with `go build ./...`, `go vet ./...`, `go test ./...` β€”
+`CGO_ENABLED=0` throughout (the stack is cgo-free by design; a cgo
+SQLite driver sneaking in is a bug).
+
+`rastrillo new` (v0.9.0+) scaffolds this shape directly; manifest/ is
+scaffolded empty with the declarative path's mounting recipe in its
+README. On an older CLI, copy the five files from `examples/notes`.
+
+## The rules that keep the app safe (SKILL.md has the full set)
+
+- Tenancy is the platform's, not the schema's: a CARLOS app serves one
+ team. A product with many teams gives each team its own hibernating
+ instance β€” isolation by process and file, never by WHERE clause.
+ Scoping separates the *users* within one instance.
+- Every query touching user-owned rows goes through
+ `scope.Owned(g, uid)` β€” reads AND writes, inside transactions too
+ (scope `tx`, never the outer handle, or the 1-connection writer pool
+ deadlocks). A row that isn't yours 404s, never 403s.
+- Never bind a request body onto a GORM model: explicit
+ `map[string]any` + `.Select` allowlist.
+- With the keymail plugin, read the viewer with `auth.From(r)` or
+ `sessions.Current(r)` (v0.11.0+: `RequireSession` stashes both) β€”
+ but never `sessions.UserID`: it returns `(0, false)` for an email
+ Subject, and dropping that `ok` scopes every query to uid 0.
+- Sign-in-time passkey 2FA (v0.11.0+): set the identity plugin's
+ `Config.SecondFactor` to `passkey.Handlers.Gate` β€” an enrolled
+ account must complete an assertion (a pending half-session between
+ factors) before any session exists; unenrolled accounts sign in
+ unchanged. Recovery codes (v0.14.0+) are the escape hatch: mint ten
+ with `RegenerateRecoveryCodes` from a page behind
+ `sessions.RequireFresh` (shown once); a lost passkey redeems one at
+ `POST /passkey/signin/recovery` β€” a plain form POST (field `code`),
+ no JS β€” minting the first-factor method plus `"+recovery"`. Sign-in
+ only: step-up still takes a real assertion.
+- Background work (v0.12.0+): never leave a button's goroutine
+ unobservable. `jobs.New` β†’ `Start(owner, name, location, fn)` with
+ owner = the session Subject; 303 the POST to `/jobs/{id}` and mount
+ `jobs.NewHandlers`' `StatusPage`/`Fragment` behind `sessions.Require`
+ at `/jobs/{id}` and `/jobs/{id}/fragment` (foreign id 404s). The
+ status page works with scripts off (`noscript` meta refresh only
+ while running); the scaffolded, app-owned `static/rastrillo.js`
+ polls the fragment for the smooth version β€” `data-poll` on the
+ fragment's root, `data-busy` on the form. v0.15.0+: also mount
+ `Events` at `/jobs/{id}/events` and pass `PageData.EventsPath` as
+ the partial's `PushURL` β€” the shim rides Server-Sent Events where
+ the browser supports them and falls back to polling on its own. `location` must be a
+ server-built path, never user input (the shim navigates to it). The
+ registry is in-memory and bounded (v0.13.0+): `Start` returns
+ `(Job, error)` β€” `ErrOwnerBusy` past four Running jobs per owner,
+ answered with your own flash copy β€” and fn's context expires after
+ fifteen minutes, the job reading Failed from then on. Honor the
+ context, and design jobs idempotent: a deploy still ends them
+ mid-flight. `jobs` is for work a *request* started and a person is
+ watching; work that has to happen at a time nobody is waiting at is
+ the platform's tick β€” see below.
+- Scheduled work (v0.19.0+): the `carlos` package is your side of the
+ platform's tick. `carlos.Tick(r)` verifies the bearer against
+ `$CARLOS_ADMIN_TOKEN` in constant time and `carlos.TickOccurrence(r)`
+ gives the dedupe key; `carlos.ScheduleAt(ctx, name, at, path)` and
+ `ScheduleCancel` register and drop one-shot timers over the control
+ socket. Declaring the recurring ones is a CLI job, not a code one
+ (`carlos schedule set`) β€” platform.md carries the contract and the
+ traps.
+
+## Manifests are the declarative path
+
+The manifest system (TOML resource β†’ generated CRUD screens) is an
+optional, equal alternative to hand-written handlers β€” mix the two per
+resource in one app, and move a resource between them freely (eject a
+generated file, or delete hand files and re-declare). Its vocabulary:
+one flat record shape per resource, three field kinds, no relations.
+Two stores, same screens (v0.16.0+): `store = "exclusive"` (default)
+is one SQL table; `store = "mergeable"` keeps each record as an
+`eventlog` stream β€” reads derive from the merged history, delete
+appends a tombstone. And, v0.11.0+, `scope = "user"`, which
+owner-filters every generated
+query by the session subject (someone else's row answers 404, the
+same 404-not-403 contract the code path enforces); mount scoped
+routes behind `sessions.Require`/`auth.RequireSession`. Relations or
+custom flows: hand-write. `examples/tickets` is the manifest-only
+shape; `examples/notes` mixes a declared, scoped resource beside hand
+handlers.
+
+## Copy from, in order
+
+1. `examples/notes` β€” the front-door example: accounts, sessions,
+ CSRF, flash, owner-scoped resources, a background export job, and a
+ two-user isolation test suite. This is the shape to imitate.
+2. `examples/tickets` β€” the declarative (manifest) path, per resource.
+
+Deploying: stamp
+`-ldflags "-X github.com/carlosframework/rastrillo.BuildVersion=<sha>"`
+or `/api/version` reports `dev`.
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..d1e1317
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,26 @@
+# πŸ€– ∞ β€” working agreement
+
+**Private conversation content and usable private keys never reach the server.**
+Every person has their own process, SQLite database and hostname. Server-visible routing metadata is an explicit, published exception, never a claim of perfect anonymity.
+
+## Settled decisions β€” 2026-09-05
+
+Paul: fork Eleven Messenger; call it ∞; connect by link without phone numbers; one instance per person; flat list of DMs and group chats; E2EE; chat first, voice/video later; Eleven logo shape with the Myriad Pro infinity glyph. Teams, communities and app marketplaces belong to Eleven.
+
+Implementation assumptions (Codex, pending product feedback): web plus isolated iOS/macOS clients; Carlos hosted by default with self-hosting supported; recoverable client-held identity and multiple devices; signed, consent-gated cross-instance delivery. These are assumptions, not attributed approvals.
+
+## Architecture and process
+
+- Apply `.agents/skills/building-carlos-apps/SKILL.md`. Its vendor-neutral intent applies; identify actual AI authorship as Codex, not Claude.
+- New personal-messenger runtime lives in `cmd/infinity` and `internal/infinity`. The inherited Eleven runtime is provenance and reusable code, not the shipping ∞ entry point. Keep its tests intact.
+- Use rastrillo Resolve/Serve and database packages; platform owns TLS, replication, routing, hibernation. Client-owned ES modules are required for E2EE; no bundler. New JS modules have a 300-line cap.
+- Write design before implementation. Enumerate metadata and threat-model limits. Do not claim Signal-equivalent security or forward secrecy without implementation and validation.
+- Additive migrations only. Explicit request types; bounded input; authenticate every owned read/write. Cross-instance requests never grant owner privileges.
+- Use isolated branches/worktrees. This session uses `.worktrees/infinity` on `infinity-v1`; the root is the fork baseline. Never push to or deploy Eleven infrastructure from this fork.
+- Run the committed ∞ check command including protocol, storage, encryption and real-browser tests. Build native clients under separate identifiers and keychain containers.
+- Local automated tests are permitted; human review uses a dedicated Carlos canary when available. Do not merge without human approval. Record deployment state and unfinished work honestly.
+- New docs and code identify AI authorship. Keep secrets and personal data out of public artifacts. No invented carbon measurements.
+
+## Deferred with triggers
+
+Voice/video: after chat is validated. Teams/communities/integrations: permanent non-goals for this product. Independent cryptographic audit: required before positioning ∞ as a high-assurance alternative to Signal.
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..5a72a64
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,25 @@
+# πŸ€– ∞ β€” personal messenger
+
+Status: implementation in progress. Product brief: Paul, 2026-09-05. Technical choices: Codex, 2026-09-05.
+
+∞ is a flat list of private conversations. A person shares a link; its recipient creates or opens their own personal instance and explicitly accepts the connection. Neither phone nor email is required. A participant's instance keeps their encrypted copy of each conversation, so another participant cannot delete that history by shutting down their server.
+
+## Trust boundary
+
+The client creates encryption and signing keys. Contact links pin the inviter's public identity in the URL fragment. Contact acceptance binds both identities; identity changes must never silently replace a trusted key. Servers store encrypted envelopes and relay them through consent-scoped inbox capabilities. The owner authenticates separately from delivery capabilities. Every message is signed and its conversation and membership are bound into authenticated content. Group membership changes are signed by the creator; removed members receive no later envelopes. Content is encrypted independently for each recipient, avoiding a reusable group key retained by removed members.
+
+Routing origins, public keys, delivery times, envelope sizes and access IPs are observable metadata. Browser code is served by an origin: an actively compromised origin can serve malicious code. A copied recipient private key can expose recorded messages under static recipient encryption; do not label this forward secrecy. Recovery material grants identity and history access; the server cannot reset it. Server-blindness protects stored content, not a compromised unlocked client.
+
+## Runtime
+
+Dedicated Go binary on rastrillo's Carlos activation contract. One SQLite file/process per person. No cross-tenant database. New protocol/runtime coexist with the inherited Eleven source while native integration migrates. A small hosted provisioning surface may allocate instances; it must never hold client private keys. Self-hosted users can start an instance directly.
+
+Durable outbound delivery uses bounded retries and idempotent message identifiers; successful enqueue is not shown as recipient delivery. Inbound requests require explicit capabilities and size limits. Outbound networking rejects private/link-local destinations and redirects in production. A Carlos scheduled request drives retries when clients are offline.
+
+## Clients
+
+Client-owned, no-build ES modules; mobile-first two-pane chat layout collapsing to a single screen. Persistent encrypted history lives on the instance. Client recovery export/import and device linking must preserve identity. iOS/macOS have separate ∞ app identifiers, icons and storage; never read Eleven's production account container.
+
+## Verification
+
+Two and three isolated instances: invite, consent, bidirectional messages, group messages, removal, offline retry, deduplication, recovery, forged sender rejection, bad capability rejection, raw SQLite/WAL plaintext checks, and browser rendering without console errors. Verify native builds and Carlos canary independently; report skipped checks.
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..35d01a9
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,10 @@
+# πŸ€– ∞ implementation plan
+
+1. Record architecture, fork provenance and Carlos skills before code.
+2. Build the personal-instance API, client-held identities and consent protocol.
+3. Build flat chat UI, group management and recovery/device continuity.
+4. Exercise independent instances with adversarial protocol and browser tests.
+5. Package isolated iOS/macOS clients and infinity glyph assets.
+6. Deploy a dedicated Carlos canary and verify; record remaining release gates.
+
+Baseline: Eleven committed HEAD at fork creation (see `git log`); upstream working changes and databases are excluded. No public fork remote has yet been configured.
diff --git a/README.md b/README.md
index a9522bd..efff2cb 100644
--- a/README.md
+++ b/README.md
@@ -1,382 +1,13 @@
-# πŸ€– Eleven Messenger
+# πŸ€– ∞
-A small, self-hostable messenger β€” DMs and group chats, no logins, no passwords,
-just secret invite links. Messages are **end-to-end encrypted**: the server only
-ever sees ciphertext, so a stolen database or server snapshot exposes no message
-content. (It does see metadata β€” screen names and who shares a thread with whom;
-see Trade-offs.)
+A personal, end-to-end encrypted messenger. Share a link, connect, chat. No phone number. One private instance per person, built on Carlos. DMs and group chats in one list; voice and video come later.
-> **`lchat`** is the engine β€” the Go binary + CLI, and the name used throughout
-> this repo. **Eleven Messenger** is the product built on it (the web client plus
-> the native iOS/macOS apps).
+This fork is under active construction. See [DESIGN.md](DESIGN.md) for the privacy boundary and [PLAN.md](PLAN.md) for implementation status. The inherited Eleven entry point is not the ∞ runtime. Build/run instructions will be added with the working runtime.
-## How the encryption works
+## Honest trade-offs
-- Each person has their own **keypair** (ECDH P-256), generated in the browser on
- first join. The public key lives in the roster; the private key never leaves the
- device (it ships, encrypted, only via an "add a device" link).
-- Every **thread** β€” a DM (2 people) or a group (N) β€” has its own **AES-GCM key**,
- wrapped to each member's public key (ECIES). Only members can unwrap it.
-- Content shared with everyone (e.g. profile photos) uses one **per-instance key**,
- wrapped to each member the same way β€” so even that reaches the server only as a
- blob it can't open.
-- The browser encrypts every message (including the sender's screen name) before
- sending, and decrypts on receipt. The server stores and relays only opaque blobs
- plus key material it can't open.
-- An invite link carries a one-time secret in its `#fragment` (e.g.
- `/join/<name>#<secret>`) used to bootstrap the joiner's keys β€” never sent to the
- server. The **admin token authorises roster management only; it is not a
- decryption key.** Even a full copy of the database reveals no message content.
+Servers can observe routing metadata, timestamps and ciphertext sizes. They must never receive message plaintext or usable client private keys. Browser code delivery trusts the serving origin. Initial recipient-key encryption does not provide Signal's double-ratchet forward secrecy. Recovery secrets unlock identity and history; losing all devices and recovery material loses access. No independent security audit has been performed.
-**Trade-offs (inherent to this model):**
+## Provenance
-- Membership is the boundary, enforced cryptographically: to read a thread you
- must be one of its members (hold its wrapped key).
-- Removing a member stops the server delivering new messages to them, but doesn't
- claw back a key they already hold (no forward secrecy on leave).
-- If a member loses their device with no backup/add-device link, their key
- material β€” and the threads only they could decrypt β€” is gone.
-- **Metadata is not encrypted.** The server can't read message content, but the
- roster stores each member's screen name in clear (separate from the encrypted
- copy carried inside each message), and the server necessarily knows who shares
- a thread with whom in order to route messages. So a server compromise reveals
- the social graph and names β€” just not what anyone said.
-- **Unsending leaves a dated marker.** Deleting a message really deletes the
- ciphertext, but the server keeps a small tombstone row β€” which thread, which
- message id, when β€” so members' devices that were offline at the time still
- learn to drop their local copy. The tombstone holds no content and no key
- material, but it is a durable record *that* a deletion happened (the server
- transiently knew that anyway β€” it performed the delete).
-- **Key distribution trusts the server.** Public keys are handed out by the
- server's roster and aren't yet verified out of band (no safety-number check).
- A *passive* compromise β€” a stolen database β€” can't use that (the content
- guarantee above holds), but an *active* malicious server could substitute a
- public key to intercept *new* conversations. Out-of-band key verification is
- planned to close this.
-- **A bot is a member whose "device" is a machine you choose.** Adding a bot
- (see `agent/`) to a thread hands that thread's key to the machine the bot
- runs on β€” so whoever controls that machine holds the key to those
- conversations, exactly as if they were a member. Threads without a bot are
- untouched. Crucially, the bot doesn't have to live on the chat server: it's a
- pure outbound client (it dials the instance and the model API; nothing ever
- dials it β€” no open ports), so you can run it on any machine you already
- trust β€” a home server behind your router, or a box on a private network
- whose only way out is to your instance and the model API. Run it there and a
- fully compromised chat server still yields nothing: the keys are on a
- machine an attacker can't even reach.
-
-## Run it
-
-```sh
-go run . # listens on :1747, creates lchat.db
-```
-
-On first start it prints an **admin link** containing your admin token β€”
-open it once and keep it secret:
-
-```
-Admin link (open once, keep it secret):
-http://localhost:1747/admin#token=...
-```
-
-Flags / env:
-
-- `-addr :1747` β€” listen address
-- `-db lchat.db` β€” database file path
-- `-reset-admin` β€” generate and print a fresh admin token
-- `LCHAT_ADMIN_TOKEN=...` β€” set a fixed admin token instead of a generated one
-
-## Build a single binary
-
-The frontend and the SQLite engine are compiled in (pure-Go SQLite, no cgo),
-so deployment is one file:
-
-```sh
-go build -o lchat . # native
-GOOS=linux GOARCH=amd64 go build -o lchat . # cross-compile for a Linux box
-```
-
-See **[Deploy](#deploy)** for how to run it in production β€” a self-hosted router
-(the default), a single instance behind your own reverse proxy, Docker, or
-another platform.
-
-## Native iOS & macOS app
-
-There's a native SwiftUI client in [`ios/`](ios/README.md) that runs on
-iPhone/iPad and on macOS via **Mac Catalyst** (same target, same code, same
-end-to-end encryption contract). The `.xcodeproj` is generated by
-[`xcodegen`](https://github.com/yonaskolb/XcodeGen), not committed.
-
-You can build both variants from the command line **without launching Xcode**
-(handy for CI or a quick compile check) β€” `CODE_SIGNING_ALLOWED=NO` skips the
-need for a signing team:
-
-```sh
-cd ios
-xcodegen generate # writes LChat.xcodeproj from project.yml
-
-# iOS (compiles against the Simulator SDK β€” no device or signing needed):
-xcodebuild -project LChat.xcodeproj -scheme LChat \
- -destination 'generic/platform=iOS Simulator' \
- -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
-
-# macOS (Mac Catalyst β€” a native build that doesn't touch the iOS Simulator):
-xcodebuild -project LChat.xcodeproj -scheme LChat \
- -destination 'platform=macOS,variant=Mac Catalyst' \
- -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
-```
-
-`-derivedDataPath build` keeps the products inside the repo (under the
-gitignored `ios/build/`) instead of a hashed folder buried in
-`~/Library/Developer/Xcode/DerivedData/`. The built apps land at:
-
-- `ios/build/Build/Products/Debug-iphonesimulator/LChat.app`
-- `ios/build/Build/Products/Debug-maccatalyst/LChat.app` ← the runnable Mac app
-
-That Mac path is still a mouthful, so symlink it somewhere handy and launch it
-(the `LChat-*.app` symlink names are gitignored):
-
-```sh
-ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app
-open LChat-mac.app # runs the Mac (Catalyst) build
-```
-
-A successful build ends in `** BUILD SUCCEEDED **`. If a Simulator destination
-can't be resolved (e.g. an out-of-date CoreSimulator), target the SDK directly
-instead: `-sdk iphonesimulator` in place of the `-destination` flag. See
-[`ios/README.md`](ios/README.md) for signing, running on a device, and how the
-client maps to the server.
-
-## Deploy
-
-lchat is a single static binary that keeps all its state in one SQLite file and
-speaks WebSockets on `/ws`. Deploying it anywhere is the same three things: run
-the binary, give it a **persistent path for the database** (`LCHAT_DB`), and put
-it behind **HTTPS** β€” required in production, since the invite token travels to
-the server and the fragment-key model assumes an encrypted transport. SQLite has
-a single writer, so run **one instance per database** (scale by adding instances,
-not replicas of one).
-
-### Self-hosted router (recommended)
-
-How we run it, and the default for hosting **one or many** chats: a single box
-runs lchat's own built-in TLS router, and each chat is a fully-isolated instance
-on its own subdomain, database, and admin token β€” automatic Let's Encrypt certs,
-no external reverse proxy, no container platform. One line installs the latest
-stable release (sha256-verified; amd64 or arm64) and bootstraps the box:
-
-```sh
-# on a fresh box (Ubuntu/Debian; inbound 80 + 443 open):
-curl -fsSL https://github.com/elevenmessenger/releases/releases/download/server/install.sh | sudo bash
-
-# add a chat β€” point its DNS A record at the box first; the TLS cert issues
-# automatically, and the printed setup link makes you the admin:
-lchat add-instance chat.example.com
-```
-
-Add as many chats as you like; each is isolated. The box then **keeps itself up
-to date** from the signed stable release channel β€” hourly checks, per-instance
-health check, automatic rollback β€” and each chat's admin picks a channel
-(stable / beta / edge) in their admin panel. The full guide (backups, bots,
-honest limitations) is **[docs/self-hosting.md](docs/self-hosting.md)**,
-published as the README of the public
-[releases repo](https://github.com/elevenmessenger/releases).
-
-Deploying your **own builds** instead (development, forks): build, stage, then
-roll out β€” zero-downtime, health-checked, auto-rollback; pin canaries so the
-auto-updater leaves them alone:
-
-```sh
-./deploy/build-linux.sh # builds + stamps a version id
-scp lchat-linux-amd64 root@box:/opt/lchat/incoming/lchat-upload
-ssh root@box /opt/lchat/lchat stage --file /opt/lchat/incoming/lchat-upload
-ssh root@box /opt/lchat/lchat deploy chat.example.com --version <ver> --pin # canary, held
-ssh root@box /opt/lchat/lchat deploy --all --version <ver> # the whole fleet
-ssh root@box /opt/lchat/lchat rollback chat.example.com # step back
-```
-
-The full design β€” routing, supervision (systemd), releases + auto-update,
-backups, and hardening β€” is in
-**[docs/instance-router.md](docs/instance-router.md)** and
-**[docs/server-releases.md](docs/server-releases.md)**.
-
-### A single instance behind your own reverse proxy
-
-The minimal path for one chat, if you'd rather run your own proxy: build, copy up,
-and terminate TLS with Caddy or nginx.
-
-```sh
-GOOS=linux GOARCH=amd64 go build -o lchat .
-scp lchat you@server:/srv/lchat/
-LCHAT_DB=/srv/lchat/lchat.db ADDR=:1747 ./lchat # via a systemd unit in practice
-```
-
-Then proxy `https://chat.example.com` β†’ `127.0.0.1:1747` (Caddy gets you TLS in
-two lines).
-
-### Docker
-
-The repo ships a `Dockerfile`: a fully static binary in a distroless image
-(~2MB of image around the binary, no shell), running as a **non-root** user.
-Runtime config is env vars:
-
-- `LCHAT_DB` β€” SQLite path; point it at the persistent volume (`/data/lchat.db`)
-- `LCHAT_ADMIN_TOKEN` β€” admin token
-- `ADDR` β€” listen address (defaults to `:1747`, matching the exposed port)
-
-> **Gotcha (learned the hard way):** the env var is `LCHAT_ADMIN_TOKEN`, *not*
-> `ADMIN_TOKEN`. Some container platforms auto-provision their own secret for a
-> bare `ADMIN_TOKEN` ("referenced in code") and inject that, silently overriding
-> whatever you set. The `LCHAT_` prefix dodges that.
-
-```sh
-# 1. Build, and generate a URL-safe admin token (it goes in a URL #fragment):
-docker build -t lchat .
-TOKEN=$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
-
-# 2. Run with a named volume for the database:
-docker run -d --name lchat --restart unless-stopped \
- -p 127.0.0.1:1747:1747 \
- -v lchat-data:/data \
- -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN" \
- lchat
-# Your admin URL is then: https://group.example.com/admin#token=$TOKEN
-```
-
-Then terminate TLS in front of it exactly as in the reverse-proxy section
-above (`https://group.example.com` β†’ `127.0.0.1:1747`; make sure the proxy
-passes the `/ws` WebSocket upgrade).
-
-The container runs as uid 65532, and `/data` in the image is owned by that
-user β€” a **named volume** (as above) inherits the ownership on first use. If
-you'd rather **bind-mount** a host directory, chown it first
-(`chown 65532:65532 /srv/lchat-data`). Keep the service at a **single
-replica** β€” SQLite has one writer.
-
-**Updating later (no data loss):** rebuild and recreate the container; the
-volume (messages, invites, push subscriptions, VAPID keys, group name)
-survives. Only deleting the *volume* wipes data.
-
-### Other platforms (Fly.io, Render, Railway, Heroku, …)
-
-Each of these should be trivial β€” it's the same single static binary plus a
-persistent volume β€” but **none is tested yet**, so treat the below as the recipe,
-not a proven path. Any host that runs a binary (or the shipped `Dockerfile`) with a
-persistent volume works the same way; the constraints are just the three from the
-top of this section:
-
-- Attach a **persistent volume** and set `LCHAT_DB` to a path on it (e.g.
- `/data/lchat.db`); the database must survive restarts and redeploys.
-- Bind the port the platform gives you: `ADDR=:$PORT` (or the default `:1747`).
-- Let the platform terminate **TLS** β€” and make sure it proxies the `/ws`
- WebSocket upgrade (all of these do).
-- Set a URL-safe `LCHAT_ADMIN_TOKEN`, and keep the service at **one instance**
- (SQLite is single-writer β€” don't scale to multiple replicas).
-
-A tested, per-platform write-up (a `fly.toml`, a Render blueprint, an `app.json`)
-would be a welcome contribution.
-
-## Flow
-
-1. **Admin** opens the admin link (roster management only β€” no decryption key) and
- creates invites. Each invite β†’ a secret link.
-2. **A guest** opens their link, picks a screen name, and joins β€” their browser
- generates a keypair and registers its public key on the roster.
-3. Anyone can start a **DM** with another member or spin up a **group**; each
- thread gets its own key, wrapped to its members. The instance is the trust
- boundary (its roster), Γ  la a private Slack/iMessage.
-
-## Posting from a script (bots, cron, CI)
-
-You can post into a thread from outside the browser **without weakening the
-encryption**. The trick: the encryption stays on *your* side, never the server's.
-Each thread has its own AES key, wrapped to every member's keypair. So a script
-posts as a real member: give it that user's **device token** (authorises the API)
-and **private key** (unwraps the thread key), plus the **thread id**. The helper
-fetches the thread's wrapped key, unwraps it locally, encrypts the message, and
-hands the server only ciphertext β€” exactly like the browser does.
-
-```sh
-# text as an argument
-tools/lchat-post.mjs --host https://group.example.com \
- --token "$TOKEN" --priv "$PRIV" --thread "$TID" "deploy finished βœ…"
-
-# or pipe it in, with a custom screen name (config via env)
-echo "build #42 passed" | tools/lchat-post.mjs --thread "$TID" --name CI
-```
-
-Config can come from flags or `LCHAT_*` env vars (`LCHAT_HOST` / `LCHAT_TOKEN` /
-`LCHAT_PRIV` / `LCHAT_THREAD`, and `LCHAT_NAME` for the screen name). The token
-and private key come from a joined session (or an add-device link). The helper
-reuses `web/static/crypto.js`, so its blobs are byte-for-byte what the browser
-produces and render as ordinary chat messages.
-
-Under the hood it `POST`s to `/api/messages?token=…` with
-`{"thread_id":"…","blob":"<ciphertext>"}`. The server checks the poster is a
-member of that thread, then stores, broadcasts, and pushes it just like a
-WebSocket `msg` frame β€” **it never sees the plaintext, the private key, or the
-thread key.** Keep those on the calling host, never on the server, so the server
-holds only ciphertext it can't open (the message-content guarantee above).
-
-## Accessibility
-
-Eleven Messenger commits to **[WCAG 2.2 Level AA](https://www.w3.org/TR/WCAG22/)**
-across the web surfaces, and to the same bar on iOS/Mac via VoiceOver, Dynamic
-Type, and Reduce Motion support. The full statement β€” what the commitment means
-for UI changes, and where we honestly are today β€” is
-**[docs/accessibility.md](docs/accessibility.md)**.
-
-## Tests
-
-```sh
-go test ./... # backend: DB logic, HTTP handlers (httptest), router control plane
-npm test # frontend logic in Node: crypto, ui/format, mini-apps, commands,
- # invites, key backup, thread keys, messages
-```
-
-The Go tests cover the invite lifecycle, admin auth, messages, push subscriptions,
-the group name + HTTP handlers (auth gating, invite/messages flow, group-name
-injection + HTML-escaping), and the self-hosted router / versioned-deploy control
-plane. `npm test` runs the pure-logic frontend modules under Node β€” the E2EE
-round-trip in `crypto.js`, plus `ui.js`/`format.js`, mini-apps, slash-commands,
-invites, and key backup. Browser/DOM end-to-end (the chat UI, onboarding) isn't in
-CI yet β€” exercise it against a throwaway instance.
-
-## Layout
-
-```
-main.go HTTP routing, handlers, admin bootstrap, //go:embed
-db.go SQLite: config, invites, messages (ciphertext only)
-hub.go WebSocket fan-out hub + message ingest
-miniapps.go mini-apps: scheduled messages + per-thread encrypted state
-push.go Web Push (self-hosted VAPID); apns.go = central APNs relay client
-unfurl.go OpenGraph link previews (SSRF-guarded fetch + cache)
-bootstrap.go, control.go, registry.go, router.go, versions.go, bot.go
- self-hosted router: provision a box, add-instance / deploy /
- rollback, the route registry, TLS front door, versioned deploys
-web/pages/*.html chat / join / admin pages
-web/static/crypto.js Web Crypto (encrypt/decrypt JSON + bytes, key wrap/unwrap)
-web/static/shell.js chat page host: sidebar, thread list, WebSocket, keys, uploads
-web/static/apps/*.js mini-app framework + apps, including the chat engine (chat.js)
-web/static/*.js also join, admin, and pure ui/format helpers (unit-tested)
-web/static/style.css styles
-tools/lchat-post.mjs CLI to post an encrypted message from a script (see above)
-docs/*.md design notes (router, threads/DMs, push relay, mini-apps, …)
-```
-
-## Licence
-
-[The Mozilla Public License, Version 2.0](LICENSE) β€” an OSI-approved,
-file-level ("weak") copyleft licence: modifications to this code stay open
-under the same terms, while a separate work that merely depends on it isn't
-dragged open with it.
-
-This is an interim choice, not a final one. In the spirit of
-[the Eleven Factors](https://11factor.org), the project's longer-term goal
-is a purpose-drafted licence β€” provisionally named GAIEST, the *Generative
-Artificial Intelligence Evolving Software Technology Licence* β€” that also
-closes the copyleft gap for hosted/SaaS use and carries a real disclosure
-requirement for AI-written material, once that draft has been through
-proper legal review and an OSI submission. Until then, AI-authorship
-marking is a **project convention**, not a licence condition β€” see
-[docs/ai-markers.md](docs/ai-markers.md).
+Forked from Eleven Messenger, retaining its MPL-2.0 license and history. Its original documentation is under `docs/infinity/eleven-upstream-readme.md`. Carlos skill sources are vendored under `.agents/skills/building-carlos-apps` from carlosframework/skills. Product direction by Paul; implementation assistance by Codex.
diff --git a/CLAUDE.md b/docs/infinity/eleven-upstream-instructions.md
similarity index 100%
rename from CLAUDE.md
rename to docs/infinity/eleven-upstream-instructions.md
diff --git a/docs/infinity/eleven-upstream-readme.md b/docs/infinity/eleven-upstream-readme.md
new file mode 100644
index 0000000..a9522bd
--- /dev/null
+++ b/docs/infinity/eleven-upstream-readme.md
@@ -0,0 +1,382 @@
+# πŸ€– Eleven Messenger
+
+A small, self-hostable messenger β€” DMs and group chats, no logins, no passwords,
+just secret invite links. Messages are **end-to-end encrypted**: the server only
+ever sees ciphertext, so a stolen database or server snapshot exposes no message
+content. (It does see metadata β€” screen names and who shares a thread with whom;
+see Trade-offs.)
+
+> **`lchat`** is the engine β€” the Go binary + CLI, and the name used throughout
+> this repo. **Eleven Messenger** is the product built on it (the web client plus
+> the native iOS/macOS apps).
+
+## How the encryption works
+
+- Each person has their own **keypair** (ECDH P-256), generated in the browser on
+ first join. The public key lives in the roster; the private key never leaves the
+ device (it ships, encrypted, only via an "add a device" link).
+- Every **thread** β€” a DM (2 people) or a group (N) β€” has its own **AES-GCM key**,
+ wrapped to each member's public key (ECIES). Only members can unwrap it.
+- Content shared with everyone (e.g. profile photos) uses one **per-instance key**,
+ wrapped to each member the same way β€” so even that reaches the server only as a
+ blob it can't open.
+- The browser encrypts every message (including the sender's screen name) before
+ sending, and decrypts on receipt. The server stores and relays only opaque blobs
+ plus key material it can't open.
+- An invite link carries a one-time secret in its `#fragment` (e.g.
+ `/join/<name>#<secret>`) used to bootstrap the joiner's keys β€” never sent to the
+ server. The **admin token authorises roster management only; it is not a
+ decryption key.** Even a full copy of the database reveals no message content.
+
+**Trade-offs (inherent to this model):**
+
+- Membership is the boundary, enforced cryptographically: to read a thread you
+ must be one of its members (hold its wrapped key).
+- Removing a member stops the server delivering new messages to them, but doesn't
+ claw back a key they already hold (no forward secrecy on leave).
+- If a member loses their device with no backup/add-device link, their key
+ material β€” and the threads only they could decrypt β€” is gone.
+- **Metadata is not encrypted.** The server can't read message content, but the
+ roster stores each member's screen name in clear (separate from the encrypted
+ copy carried inside each message), and the server necessarily knows who shares
+ a thread with whom in order to route messages. So a server compromise reveals
+ the social graph and names β€” just not what anyone said.
+- **Unsending leaves a dated marker.** Deleting a message really deletes the
+ ciphertext, but the server keeps a small tombstone row β€” which thread, which
+ message id, when β€” so members' devices that were offline at the time still
+ learn to drop their local copy. The tombstone holds no content and no key
+ material, but it is a durable record *that* a deletion happened (the server
+ transiently knew that anyway β€” it performed the delete).
+- **Key distribution trusts the server.** Public keys are handed out by the
+ server's roster and aren't yet verified out of band (no safety-number check).
+ A *passive* compromise β€” a stolen database β€” can't use that (the content
+ guarantee above holds), but an *active* malicious server could substitute a
+ public key to intercept *new* conversations. Out-of-band key verification is
+ planned to close this.
+- **A bot is a member whose "device" is a machine you choose.** Adding a bot
+ (see `agent/`) to a thread hands that thread's key to the machine the bot
+ runs on β€” so whoever controls that machine holds the key to those
+ conversations, exactly as if they were a member. Threads without a bot are
+ untouched. Crucially, the bot doesn't have to live on the chat server: it's a
+ pure outbound client (it dials the instance and the model API; nothing ever
+ dials it β€” no open ports), so you can run it on any machine you already
+ trust β€” a home server behind your router, or a box on a private network
+ whose only way out is to your instance and the model API. Run it there and a
+ fully compromised chat server still yields nothing: the keys are on a
+ machine an attacker can't even reach.
+
+## Run it
+
+```sh
+go run . # listens on :1747, creates lchat.db
+```
+
+On first start it prints an **admin link** containing your admin token β€”
+open it once and keep it secret:
+
+```
+Admin link (open once, keep it secret):
+http://localhost:1747/admin#token=...
+```
+
+Flags / env:
+
+- `-addr :1747` β€” listen address
+- `-db lchat.db` β€” database file path
+- `-reset-admin` β€” generate and print a fresh admin token
+- `LCHAT_ADMIN_TOKEN=...` β€” set a fixed admin token instead of a generated one
+
+## Build a single binary
+
+The frontend and the SQLite engine are compiled in (pure-Go SQLite, no cgo),
+so deployment is one file:
+
+```sh
+go build -o lchat . # native
+GOOS=linux GOARCH=amd64 go build -o lchat . # cross-compile for a Linux box
+```
+
+See **[Deploy](#deploy)** for how to run it in production β€” a self-hosted router
+(the default), a single instance behind your own reverse proxy, Docker, or
+another platform.
+
+## Native iOS & macOS app
+
+There's a native SwiftUI client in [`ios/`](ios/README.md) that runs on
+iPhone/iPad and on macOS via **Mac Catalyst** (same target, same code, same
+end-to-end encryption contract). The `.xcodeproj` is generated by
+[`xcodegen`](https://github.com/yonaskolb/XcodeGen), not committed.
+
+You can build both variants from the command line **without launching Xcode**
+(handy for CI or a quick compile check) β€” `CODE_SIGNING_ALLOWED=NO` skips the
+need for a signing team:
+
+```sh
+cd ios
+xcodegen generate # writes LChat.xcodeproj from project.yml
+
+# iOS (compiles against the Simulator SDK β€” no device or signing needed):
+xcodebuild -project LChat.xcodeproj -scheme LChat \
+ -destination 'generic/platform=iOS Simulator' \
+ -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
+
+# macOS (Mac Catalyst β€” a native build that doesn't touch the iOS Simulator):
+xcodebuild -project LChat.xcodeproj -scheme LChat \
+ -destination 'platform=macOS,variant=Mac Catalyst' \
+ -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
+```
+
+`-derivedDataPath build` keeps the products inside the repo (under the
+gitignored `ios/build/`) instead of a hashed folder buried in
+`~/Library/Developer/Xcode/DerivedData/`. The built apps land at:
+
+- `ios/build/Build/Products/Debug-iphonesimulator/LChat.app`
+- `ios/build/Build/Products/Debug-maccatalyst/LChat.app` ← the runnable Mac app
+
+That Mac path is still a mouthful, so symlink it somewhere handy and launch it
+(the `LChat-*.app` symlink names are gitignored):
+
+```sh
+ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app
+open LChat-mac.app # runs the Mac (Catalyst) build
+```
+
+A successful build ends in `** BUILD SUCCEEDED **`. If a Simulator destination
+can't be resolved (e.g. an out-of-date CoreSimulator), target the SDK directly
+instead: `-sdk iphonesimulator` in place of the `-destination` flag. See
+[`ios/README.md`](ios/README.md) for signing, running on a device, and how the
+client maps to the server.
+
+## Deploy
+
+lchat is a single static binary that keeps all its state in one SQLite file and
+speaks WebSockets on `/ws`. Deploying it anywhere is the same three things: run
+the binary, give it a **persistent path for the database** (`LCHAT_DB`), and put
+it behind **HTTPS** β€” required in production, since the invite token travels to
+the server and the fragment-key model assumes an encrypted transport. SQLite has
+a single writer, so run **one instance per database** (scale by adding instances,
+not replicas of one).
+
+### Self-hosted router (recommended)
+
+How we run it, and the default for hosting **one or many** chats: a single box
+runs lchat's own built-in TLS router, and each chat is a fully-isolated instance
+on its own subdomain, database, and admin token β€” automatic Let's Encrypt certs,
+no external reverse proxy, no container platform. One line installs the latest
+stable release (sha256-verified; amd64 or arm64) and bootstraps the box:
+
+```sh
+# on a fresh box (Ubuntu/Debian; inbound 80 + 443 open):
+curl -fsSL https://github.com/elevenmessenger/releases/releases/download/server/install.sh | sudo bash
+
+# add a chat β€” point its DNS A record at the box first; the TLS cert issues
+# automatically, and the printed setup link makes you the admin:
+lchat add-instance chat.example.com
+```
+
+Add as many chats as you like; each is isolated. The box then **keeps itself up
+to date** from the signed stable release channel β€” hourly checks, per-instance
+health check, automatic rollback β€” and each chat's admin picks a channel
+(stable / beta / edge) in their admin panel. The full guide (backups, bots,
+honest limitations) is **[docs/self-hosting.md](docs/self-hosting.md)**,
+published as the README of the public
+[releases repo](https://github.com/elevenmessenger/releases).
+
+Deploying your **own builds** instead (development, forks): build, stage, then
+roll out β€” zero-downtime, health-checked, auto-rollback; pin canaries so the
+auto-updater leaves them alone:
+
+```sh
+./deploy/build-linux.sh # builds + stamps a version id
+scp lchat-linux-amd64 root@box:/opt/lchat/incoming/lchat-upload
+ssh root@box /opt/lchat/lchat stage --file /opt/lchat/incoming/lchat-upload
+ssh root@box /opt/lchat/lchat deploy chat.example.com --version <ver> --pin # canary, held
+ssh root@box /opt/lchat/lchat deploy --all --version <ver> # the whole fleet
+ssh root@box /opt/lchat/lchat rollback chat.example.com # step back
+```
+
+The full design β€” routing, supervision (systemd), releases + auto-update,
+backups, and hardening β€” is in
+**[docs/instance-router.md](docs/instance-router.md)** and
+**[docs/server-releases.md](docs/server-releases.md)**.
+
+### A single instance behind your own reverse proxy
+
+The minimal path for one chat, if you'd rather run your own proxy: build, copy up,
+and terminate TLS with Caddy or nginx.
+
+```sh
+GOOS=linux GOARCH=amd64 go build -o lchat .
+scp lchat you@server:/srv/lchat/
+LCHAT_DB=/srv/lchat/lchat.db ADDR=:1747 ./lchat # via a systemd unit in practice
+```
+
+Then proxy `https://chat.example.com` β†’ `127.0.0.1:1747` (Caddy gets you TLS in
+two lines).
+
+### Docker
+
+The repo ships a `Dockerfile`: a fully static binary in a distroless image
+(~2MB of image around the binary, no shell), running as a **non-root** user.
+Runtime config is env vars:
+
+- `LCHAT_DB` β€” SQLite path; point it at the persistent volume (`/data/lchat.db`)
+- `LCHAT_ADMIN_TOKEN` β€” admin token
+- `ADDR` β€” listen address (defaults to `:1747`, matching the exposed port)
+
+> **Gotcha (learned the hard way):** the env var is `LCHAT_ADMIN_TOKEN`, *not*
+> `ADMIN_TOKEN`. Some container platforms auto-provision their own secret for a
+> bare `ADMIN_TOKEN` ("referenced in code") and inject that, silently overriding
+> whatever you set. The `LCHAT_` prefix dodges that.
+
+```sh
+# 1. Build, and generate a URL-safe admin token (it goes in a URL #fragment):
+docker build -t lchat .
+TOKEN=$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
+
+# 2. Run with a named volume for the database:
+docker run -d --name lchat --restart unless-stopped \
+ -p 127.0.0.1:1747:1747 \
+ -v lchat-data:/data \
+ -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN" \
+ lchat
+# Your admin URL is then: https://group.example.com/admin#token=$TOKEN
+```
+
+Then terminate TLS in front of it exactly as in the reverse-proxy section
+above (`https://group.example.com` β†’ `127.0.0.1:1747`; make sure the proxy
+passes the `/ws` WebSocket upgrade).
+
+The container runs as uid 65532, and `/data` in the image is owned by that
+user β€” a **named volume** (as above) inherits the ownership on first use. If
+you'd rather **bind-mount** a host directory, chown it first
+(`chown 65532:65532 /srv/lchat-data`). Keep the service at a **single
+replica** β€” SQLite has one writer.
+
+**Updating later (no data loss):** rebuild and recreate the container; the
+volume (messages, invites, push subscriptions, VAPID keys, group name)
+survives. Only deleting the *volume* wipes data.
+
+### Other platforms (Fly.io, Render, Railway, Heroku, …)
+
+Each of these should be trivial β€” it's the same single static binary plus a
+persistent volume β€” but **none is tested yet**, so treat the below as the recipe,
+not a proven path. Any host that runs a binary (or the shipped `Dockerfile`) with a
+persistent volume works the same way; the constraints are just the three from the
+top of this section:
+
+- Attach a **persistent volume** and set `LCHAT_DB` to a path on it (e.g.
+ `/data/lchat.db`); the database must survive restarts and redeploys.
+- Bind the port the platform gives you: `ADDR=:$PORT` (or the default `:1747`).
+- Let the platform terminate **TLS** β€” and make sure it proxies the `/ws`
+ WebSocket upgrade (all of these do).
+- Set a URL-safe `LCHAT_ADMIN_TOKEN`, and keep the service at **one instance**
+ (SQLite is single-writer β€” don't scale to multiple replicas).
+
+A tested, per-platform write-up (a `fly.toml`, a Render blueprint, an `app.json`)
+would be a welcome contribution.
+
+## Flow
+
+1. **Admin** opens the admin link (roster management only β€” no decryption key) and
+ creates invites. Each invite β†’ a secret link.
+2. **A guest** opens their link, picks a screen name, and joins β€” their browser
+ generates a keypair and registers its public key on the roster.
+3. Anyone can start a **DM** with another member or spin up a **group**; each
+ thread gets its own key, wrapped to its members. The instance is the trust
+ boundary (its roster), Γ  la a private Slack/iMessage.
+
+## Posting from a script (bots, cron, CI)
+
+You can post into a thread from outside the browser **without weakening the
+encryption**. The trick: the encryption stays on *your* side, never the server's.
+Each thread has its own AES key, wrapped to every member's keypair. So a script
+posts as a real member: give it that user's **device token** (authorises the API)
+and **private key** (unwraps the thread key), plus the **thread id**. The helper
+fetches the thread's wrapped key, unwraps it locally, encrypts the message, and
+hands the server only ciphertext β€” exactly like the browser does.
+
+```sh
+# text as an argument
+tools/lchat-post.mjs --host https://group.example.com \
+ --token "$TOKEN" --priv "$PRIV" --thread "$TID" "deploy finished βœ…"
+
+# or pipe it in, with a custom screen name (config via env)
+echo "build #42 passed" | tools/lchat-post.mjs --thread "$TID" --name CI
+```
+
+Config can come from flags or `LCHAT_*` env vars (`LCHAT_HOST` / `LCHAT_TOKEN` /
+`LCHAT_PRIV` / `LCHAT_THREAD`, and `LCHAT_NAME` for the screen name). The token
+and private key come from a joined session (or an add-device link). The helper
+reuses `web/static/crypto.js`, so its blobs are byte-for-byte what the browser
+produces and render as ordinary chat messages.
+
+Under the hood it `POST`s to `/api/messages?token=…` with
+`{"thread_id":"…","blob":"<ciphertext>"}`. The server checks the poster is a
+member of that thread, then stores, broadcasts, and pushes it just like a
+WebSocket `msg` frame β€” **it never sees the plaintext, the private key, or the
+thread key.** Keep those on the calling host, never on the server, so the server
+holds only ciphertext it can't open (the message-content guarantee above).
+
+## Accessibility
+
+Eleven Messenger commits to **[WCAG 2.2 Level AA](https://www.w3.org/TR/WCAG22/)**
+across the web surfaces, and to the same bar on iOS/Mac via VoiceOver, Dynamic
+Type, and Reduce Motion support. The full statement β€” what the commitment means
+for UI changes, and where we honestly are today β€” is
+**[docs/accessibility.md](docs/accessibility.md)**.
+
+## Tests
+
+```sh
+go test ./... # backend: DB logic, HTTP handlers (httptest), router control plane
+npm test # frontend logic in Node: crypto, ui/format, mini-apps, commands,
+ # invites, key backup, thread keys, messages
+```
+
+The Go tests cover the invite lifecycle, admin auth, messages, push subscriptions,
+the group name + HTTP handlers (auth gating, invite/messages flow, group-name
+injection + HTML-escaping), and the self-hosted router / versioned-deploy control
+plane. `npm test` runs the pure-logic frontend modules under Node β€” the E2EE
+round-trip in `crypto.js`, plus `ui.js`/`format.js`, mini-apps, slash-commands,
+invites, and key backup. Browser/DOM end-to-end (the chat UI, onboarding) isn't in
+CI yet β€” exercise it against a throwaway instance.
+
+## Layout
+
+```
+main.go HTTP routing, handlers, admin bootstrap, //go:embed
+db.go SQLite: config, invites, messages (ciphertext only)
+hub.go WebSocket fan-out hub + message ingest
+miniapps.go mini-apps: scheduled messages + per-thread encrypted state
+push.go Web Push (self-hosted VAPID); apns.go = central APNs relay client
+unfurl.go OpenGraph link previews (SSRF-guarded fetch + cache)
+bootstrap.go, control.go, registry.go, router.go, versions.go, bot.go
+ self-hosted router: provision a box, add-instance / deploy /
+ rollback, the route registry, TLS front door, versioned deploys
+web/pages/*.html chat / join / admin pages
+web/static/crypto.js Web Crypto (encrypt/decrypt JSON + bytes, key wrap/unwrap)
+web/static/shell.js chat page host: sidebar, thread list, WebSocket, keys, uploads
+web/static/apps/*.js mini-app framework + apps, including the chat engine (chat.js)
+web/static/*.js also join, admin, and pure ui/format helpers (unit-tested)
+web/static/style.css styles
+tools/lchat-post.mjs CLI to post an encrypted message from a script (see above)
+docs/*.md design notes (router, threads/DMs, push relay, mini-apps, …)
+```
+
+## Licence
+
+[The Mozilla Public License, Version 2.0](LICENSE) β€” an OSI-approved,
+file-level ("weak") copyleft licence: modifications to this code stay open
+under the same terms, while a separate work that merely depends on it isn't
+dragged open with it.
+
+This is an interim choice, not a final one. In the spirit of
+[the Eleven Factors](https://11factor.org), the project's longer-term goal
+is a purpose-drafted licence β€” provisionally named GAIEST, the *Generative
+Artificial Intelligence Evolving Software Technology Licence* β€” that also
+closes the copyleft gap for hosted/SaaS use and carries a real disclosure
+requirement for AI-written material, once that draft has been through
+proper legal review and an OSI submission. Until then, AI-authorship
+marking is a **project convention**, not a licence condition β€” see
+[docs/ai-markers.md](docs/ai-markers.md).
diff --git a/docs/infinity/rastrillo-authoring.md b/docs/infinity/rastrillo-authoring.md
new file mode 100644
index 0000000..c439baa
--- /dev/null
+++ b/docs/infinity/rastrillo-authoring.md
@@ -0,0 +1,422 @@
+---
+name: rastrillo
+description: Build a multi-user CARLOS app: GORM models, chi routes, sessions, owner-scoped queries.
+---
+
+# Rastrillo
+
+CARLOS middle layer, not full-stack: you write GORM models, `net/http`
+handlers on a chi router and `html/template` pages; it supplies the
+database opener, session store, identity plugins, CSRF, owner scoping,
+form helpers. Module `amadan.net/rastrillo/rastrillo`; worked
+reference `examples/notes` β€” in the repository, **not** in the
+published module (Go excludes nested modules from a zip), so read it
+there, never in your checkout. Rare traps get one sentence plus a page:
+`docs/site/<page>.md`, or `curl -s https://rastrillo.org/docs/<page>.md`.
+
+## 0. Start here
+
+```sh
+go install amadan.net/rastrillo/rastrillo/cmd/rastrillo@latest
+rastrillo new notes && cd notes && go mod tidy && go test ./...
+```
+
+`rastrillo new --theme=day|plain|signal --shell=column|topbar|sidebar|console <name>`
+(also `--icons`, `--icon-delivery`, `--ux`) writes the whole Β§1 shape plus
+`go.mod`, migrations, templates, static assets, app-owned icons, a test
+harness including the browser drive, a `Makefile` whose `ci` target is the
+gate, and `.amadan/ci.d/` steps. It compiles, its tests pass and it
+serves before you write a line β€” hand-writing any of it redoes the
+scaffold's work. The theme lands as `static/theme.css`, the shell as
+`templates/layout.html`, both app-owned from then on.
+docs/site/templates.md
+
+The scaffolded `AGENTS.md` is the source of truth for that app's code;
+this file stays the framework's. `rastrillo generate` writes `gen/`
+from `manifest/` β€” commit it, never hand-edit; add `generate --check`
+to `make ci`. `rastrillo dev` watches, regenerates, rebuilds and
+restarts. docs/site/getting-started.md
+
+`rastrillo doctor [--fix]` compares `static/`'s vendored files with the
+CLI's own copies. The scaffolded pin test, not doctor, is the standing
+gate. docs/site/cli.md
+
+**Reach for a manifest before hand-writing.** A `manifest/*.toml`
+resource generates CRUD screens β€” field kinds text, textarea, money; no
+relations; `scope = "user"` owner-filters by session subject. Inside
+that vocabulary the closing checklist is correct by construction.
+docs/site/manifests.md
+
+## 1. App shape
+
+Five files plus `migrations.go` beside `models.go` (Β§2):
+`internal/<app>/models.go` (plain GORM structs), `app.go` (wiring:
+migrate.Apply, sessions, identity plugin, router), `handlers.go`
+(owner-scoped CRUD), `render.go` (embedded templates,
+flash/session-aware page data), `cmd/<app>/main.go` (Resolve ->
+db.Open -> App -> Serve).
+
+Imports: `amadan.net/rastrillo/rastrillo` and subpackages `db`,
+`migrate`, `scope`, `sessions`, `password`, `csrf`, `flash`, `form`,
+`jobs`; `github.com/go-chi/chi/v5`; `gorm.io/gorm`.
+
+`cmd/<app>/main.go`, in order:
+`opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "notes.db",
+Logger: logger})`; `d, err := db.Open(opts.DBPath, logger)` (`*db.DB`,
+Β§2); `defer d.Close()`; `mux, err := notes.App(d, origin, logger)`;
+`opts.Mux = mux`; `opts.DBPath = ""`; `rastrillo.Serve(opts)`. On any
+err: `logger.Error`, `os.Exit(1)`.
+
+**Use `Resolve` + `Serve`, never `Run`, when the app opens its own
+database:** `Run` re-parses argv and repopulates `Options.DBPath`, so
+`Serve` opens a second connection to the file `db.Open` owns.
+
+Resolve/Serve supply the platform contract β€” activation argv,
+LISTEN_FDS, $STATE_DIRECTORY, /healthz, /api/version, SIGTERM drain,
+baseline security headers (your own Set wins). Never hand-roll any of it.
+
+`App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux,
+error)` in `app.go`, in order:
+`migrate.Apply(context.Background(), d, BootSchema)`; writer `*sql.DB`
+via `d.G.DB()` (sessions wants it);
+`sessions.New(sessions.Config{DB: writer, Origin: origin, Logger:
+logger})`; one handler struct holding `d.G`;
+`ph, err := password.New(password.Config{...})` (Β§5); `r :=
+chi.NewRouter()`; `r.Use(csrf.Protect(origin))`; `ph`'s handlers at
+GET+POST `/signin`, `/signup` and POST `/signout` (Β§5); owned-model
+CRUD (Β§3/Β§4) inside
+`r.Group(func(r chi.Router) { r.Use(sess.Require); ... })`; return
+`http.NewServeMux()` with `mux.Handle("/", r)`. `render.go` parses one
+`*template.Template` per page (layout + page), so two pages can both
+define `"content"`.
+
+Locales: `Options.Locales`/`DefaultLocale`/`LocaleFS`, flat TOML per
+code; twelve ship translated. **Any locale you add must translate the
+`rastrillo.ui.*` keys or `generate --check` fails.**
+docs/site/localization.md
+
+## 2. Data
+
+Models are plain GORM structs β€” no base type, no embedding. Owner
+column `UserID int64` tagged `gorm:"index"` is what `scope.Owned`
+filters on.
+
+`db.Open(path, logger)` returns `*db.DB`: `.G` is the `*gorm.DB`,
+SQLite-wired (one file, WAL, one writer connection, several readers,
+routed per statement). `d.Close()` closes both pools; `d.G.DB()` returns
+the writer `*sql.DB` for database/sql packages.
+
+**Bytes go in the object store, never on disk or in a column.** The
+instance's filesystem is ephemeral and it hibernates, so a saved upload
+is gone by the next request β€” silently, never at build time. The
+platform delivers a bucket as `CARLOS_STORE_*`; `blobs.S3FromEnv()`
+binds it and answers `(nil, nil)` β€” not an error β€” when there is none,
+so checking `err` alone panics later; dev falls back to
+`blobs.Dir(root)`. The row keeps a `blobs.Ref` (hex SHA-256 address,
+size, content type), and under 4 KiB may sit inline in SQLite β€” a rule
+stated everywhere and enforced nowhere. Presigned GET/PUT move bytes
+browser-to-bucket without the app proxying.
+docs/site/reference/blobs.md
+
+Schema changes: edit a model, `rastrillo migration generate`, read the
+SQL before committing. Migrations apply once at boot, ledgered β€” never
+re-run, never reversed. `migrations.go` declares two `*migrate.Set`:
+`Schema` (the app's own; `generate`/`check` diff it against `Models`)
+and `BootSchema = migrate.Merge(sessions.Schema, Schema)` (argument
+order = apply order) β€” everything `App()` applies. Subsystems go in
+`BootSchema`, never `Schema`, or `check` proposes dropping their
+tables. Never edit a shipped migration; add a new one. Renames are
+hand-written (`rastrillo migration new rename_x`); destructive changes
+need `--allow-destructive`.
+
+**Old database.** Boot refuses on a structural diff. Run
+`migration baseline --db <path> --through <id>` FIRST, then the missing
+migration by hand β€” bare `baseline` silently strands a pending data
+migration. docs/site/migrations.md
+
+**The first deploy of a version with migrations must be
+schema-neutral.** Generate `0001_init` from the models as already
+deployed, ship it alone, change a model only in a later release β€” else
+boot refuses on the new column, and `baseline` there strands it for
+good.
+
+**Deleting a secret from SQLite does not unwrite it.** A value is in
+`app.db-wal` from the insert β€” *before* it reaches `app.db` β€” and
+survives `DELETE`: the WAL keeps the page as it was. So a gate
+grepping only `app.db` passes while the plaintext sits in the WAL, and
+Litestream has shipped it. `secure_delete = ON` cleans `app.db` at the
+next checkpoint, never the WAL: set it on the writer (one connection,
+so it sticks) and treat it as damage control. For a value that must
+not be at rest the only answer is not to write it β€” seal, hash, or key
+by digest.
+
+## 3. Scoping
+
+Scoping separates users, never tenants: a CARLOS app serves one team; a
+multi-team product runs an instance per team (idle instances hibernate
+free). Roles/membership are the
+`amadan.net/rastrillo/idear` addon (Owner/Admin/Member, invitations, a
+membership gate that mints no session) β€” never core, never a subpath of
+this repository. docs/site/addons.md
+
+One seam, every read and write:
+`func (a *app) owned(r *http.Request) *gorm.DB { uid, _ :=
+sessions.UserID(r); return scope.Owned(a.db, uid) }`.
+`scope.Owned(g, owner)` adds `WHERE user_id = ?`; `scope.OwnedBy(g,
+column, owner)` takes another owner column, panicking unless plain
+`lower_snake` (it is interpolated into SQL). Never
+First/Find/Update/Delete an owned model without the filter β€” in
+`d.G.Transaction` too: scope the callback's `tx`, never `d.G` (a `d.G`
+statement there hangs on the writer connection the transaction holds).
+
+Scope the write, not just the read that loaded the row β€” the SQL must
+carry `WHERE user_id = ? AND id = ?`: after
+`a.owned(r).First(&n, id)` (404 on err),
+`a.owned(r).Model(&n).Select("Title", "Body", "UpdatedAt").Updates(...)`.
+
+A row that isn't yours doesn't exist: 404, never 403 β€” malformed `{id}`
+too (`strconv.ParseInt` failure returns `gorm.ErrRecordNotFound`). A
+join table is scoped through BOTH sides, checked explicitly; the
+stricter reading wins. Creating takes the owner from the session, never
+the form: `Note{UserID: uid, ...}`, `uid` from `sessions.UserID(r)`.
+
+## 4. Forms and mass assignment
+
+Never bind a request body onto a GORM model. Read each permitted field
+by name (`r.PostFormValue("Title")`), write through
+`.Select("Title", "Body").Updates(...)` β€” `Select`'s strings are GORM
+field names, the allowlist keeping unexpected fields out of columns.
+
+Validate with `form.Parse(r, form.Field{Name: "title", Required: true},
+form.Field{Name: "body", Kind: form.Textarea})` β€” one declaration per
+field. On `!p.OK()`: `w.WriteHeader(422)`, re-render seeding values
+(`p.String("title")`; `p.Echo()` is the seed-back map for
+map-shaped views). Write the status before rendering; the render helper
+writes none. Money is `int64` cents: `form.Money`, read `p.Cents`,
+parse `form.ParseCents` (no `$`, two decimals max, `""` = zero), seed
+`form.FormatCentsPlain`, display `form.FormatCents`.
+
+`form.Date`/`Time`/`DateTime` parse fixed layouts only, in
+`Field.Location`; `form.Range(p, "starts", "ends")` checks
+end-before-start. Three traps: empty, unparseable and undeclared names
+all read back as the zero time β€” ask `p.OK()`, never `IsZero`; their
+error values are `rastrillo.ui.*` **keys**, not sentences, so render
+`"Error" (T (index .Errors "Starts"))`; and `field-daterange`'s two
+halves need distinct `Name`s. docs/site/forms.md
+
+After a mutation: `flash.Set(w, "notice", "...")`, then 303; the render
+helper calls `flash.Take(w, r)` once per page and the layout renders it.
+
+## 5. Sessions and identity
+
+`sessions` owns signed-in state: SQLite rows (sign-out/revocation are
+real), `__Host-` cookies on https, 30-day TTL. An identity plugin
+verifies a credential and calls `SignIn` β€” the whole contract.
+
+- `csrf.Protect(origin)` mounts app-wide (`r.Use`): refuses cross-origin
+ POST/PUT/PATCH/DELETE via `Sec-Fetch-Site`/`Origin`, no tokens.
+- Signed-in routes: chi `Group` with `s.Require` β€” signed-out GET/HEAD
+ redirects to `SigninPath` with a same-site `return_to`; the rest 403.
+ `s.Middleware` only resolves, blocks nothing.
+- `s.RequireFresh(maxAge)` step-up: past maxAge, GET/HEAD goes to
+ `SigninPath?reauth=1`; re-signing-in or a `passkey` assertion rotates
+ fresh (2FA: `Config.SecondFactor` = passkey's `Gate`). Lost passkey: a
+ recovery code redeems at POST `/passkey/signin/recovery`, minted by
+ `RegenerateRecoveryCodes` behind `RequireFresh`. docs/site/passkeys.md
+- Viewer: `sessions.UserID(r)` (int64, ok) or `sessions.Current(r)`
+ (Subject, Method, AuthTime, At). Past `Require`, `ok` holds only when
+ the plugin's Subject is a numeric user id β€” see the `auth` warning.
+ docs/site/magic-links.md
+- Sign-in redirects go through `sessions.SafeReturn(r, "/")`, never a
+ raw `return_to`. `s.Sweep(time.Now())` deletes expired rows.
+
+**Password plugin.** `password.New` needs `Sessions`, `Lookup`,
+`RenderSignin`; nil `Create` disables signup, and `RenderSignup` is
+required whenever `Create` is set. Two traps: any `Create` error reads
+as a duplicate email unless it wraps `password.ErrRefused` (via
+`password.Refuse(msg)`); and the plugin writes the status (422/403/429),
+so a render callback must not. Signin and Signup share a per-email
+failure budget answering 429 (IP throttling belongs to deployment).
+docs/site/passwords.md
+
+**Magic links** (`rastrillo/auth`: sign-in by emailed link, upgrading
+to the keymail ceremony where the address has one): `auth.New` with
+`Begin`/`Callback`/`Verify`/`Signout` and `RequireSession`, same
+`sessions` core, same rate-limit shape. **Under `auth`, never
+`sessions.UserID`:** the Subject is the verified email, so it returns
+`(0, false)` and the Β§3 seam would scope every query to `user_id = 0`.
+Use `auth.From(r)` or `sessions.Current(r)` and map the address to your
+user row's id before scoping.
+
+**CSP:** the baseline's `form-action 'self'` is enforced across a form
+submission's whole redirect chain, so `Begin`'s 303 out to the address's
+keymail server is refused β€” as is any POST of yours that lands
+off-origin. `Options.CSP` replaces the policy wholesale, so restate it
+with the origin appended: `default-src 'self'; style-src 'self'
+'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri
+'self'; form-action 'self' https://keymail.dev`. Only listed servers
+work β€” a federated address on another keymail host is still refused.
+
+**Public forms** (`rastrillo/pow`: the front door for anything the
+internet can post to β€” proof of work, sealed challenge, honeypot).
+`pow.New(Config{InstanceKey, Nonces: pow.SQLNonces(d.Writer())})` plus
+`pow.Schema`; `Issue(now)` mints a challenge and **writes nothing** (a
+row per challenge makes every page view a serialised write);
+`Challenge.Fields`/`FormAttrs` render the hidden fields and the
+honeypot; `Check(r, binding)` runs honeypot β†’ seal β†’ clock β†’ proof of
+work β†’ single-use nonce, in that order, returning a closed `Reason`.
+The work is bound to the submitted value, so one solve buys one
+address, not a list. Render the submit **disabled** inside a form with a
+`<noscript>` β€” the module enables it, and JS cannot enable a control
+inside `<noscript>`. Serve both halves from the module
+(`rastrillo.NewAssets(pow.Assets())`); never vendor the JS, because a
+copy that drifts from the Go verifier fails silently in the browser.
+docs/site/reference/pow.md
+
+## 6. Background work
+
+`jobs` runs observable in-memory goroutines: a restart kills them, fn's
+ctx expires at 15 min (job turns Failed) β€” keep jobs idempotent, honor
+ctx. `j := jobs.New(logger)` once; `j.Start(owner, name, location, fn)`
+runs `fn(ctx, progress func(string)) error`, returns `(Job, error)`;
+`ErrOwnerBusy` past 4 Running per owner (flash your own copy). 303 the
+caller to `/jobs/`+job.ID. Owner is the session **Subject**, not
+`sessions.UserID` β€” key job rows the same way. fn's error text reaches
+the owner. `j.Get(id, owner)` 404s a foreign or unknown id.
+
+`jobs.NewHandlers(jobs.Config{Jobs, Render, RenderFragment})` returns
+`StatusPage`/`Fragment`/`Events` (SSE); mount all three in the
+`sess.Require` group under `/jobs/{id}`. Two traps: `Render` draws the
+whole page and `RenderFragment` the partial alone, or the layout nests
+on the next poll; and it must work with scripts off β€” a `<noscript>`
+meta refresh only while Running, or a failed page refreshes forever.
+docs/site/jobs.md
+
+The only JavaScript is app-owned `static/rastrillo.js`, inert until
+markup opts in. `data-poll="URL"` + `data-poll-every="2"` swap the
+element for the fetched fragment, repeating while the fragment still
+carries `data-poll` (ui's `job-status` partial drops it once done); the
+partial's `PushURL` (= `EventsPath`) emits `data-poll-push` and the
+shim rides SSE, falling back to polling. Every submit button gets a
+spinner, `aria-busy` and a double-submit guard by DEFAULT;
+`data-busy="false"` (form or button) opts out, `data-busy-label`
+retitles. Manners, not idempotency β€” the server still refuses the
+second write.
+
+Hibernation means a `time.Ticker` is not a scheduler. Declare recurring
+work outside the app (`carlos schedule set -name sync -every 6h -path
+/jobs/sync`); the platform wakes the instance and POSTs there. Guard
+with `carlos.Tick(r)` (bearer == `$CARLOS_ADMIN_TOKEN`, constant-time,
+false with no token) and work inside the request β€” 202-plus-goroutine
+hibernates mid-job; 2xx done, 5xx retry, 4xx don't. At-least-once
+delivery: dedupe on `carlos.TickOccurrence(r)` (stable across retries),
+never the clock. One-offs: `carlos.ScheduleAt(ctx,
+name, at, path)` (upsert by name; `ErrNotOnCarlos` off-platform,
+`ErrDeclaredSchedule`, `ErrTooManyTimers`) and `carlos.ScheduleCancel`.
+
+## 7. Screens and flows
+
+**One screen, one job.** A screen shows a thing, or asks for one thing β€”
+never both. The failure it prevents is stacking: a list page that also
+carries a create form, an import panel and a dropzone, so the first
+thing a person meets is four half-started decisions and no obvious one.
+
+Nearly every interaction is the same short flow, as full pages or as
+modals β€” a modal here is its own URL, so it is the same four steps and
+the same back button, not a second mode:
+
+1. **A link naming the action**, on the screen you are already on: "New
+ sheet", "Import a spreadsheet". A link or button, never the form
+ itself inlined.
+2. **A page for that action alone** β€” `GET /sheets/new` β€” one form, one
+ primary button. Two ways to make a sheet are two pages behind two
+ links, never two panels side by side.
+3. **An interstitial only when the work outlives the request**: the jobs
+ status page of Β§6. Skip it when the POST answers immediately.
+4. **A confirmation** β€” 303 to the new thing's show page, with a flash
+ notice (Β§4). Re-rendering the form is not a confirmation.
+
+An empty list is step 1, not an exception: `empty-state` says what the
+screen is for and carries the one link β€” do not pre-empt it with the
+create form. Destructive actions are the same shape, with `confirm-form`
+on its own URL at step 2, never a modal fired from the row.
+
+## 8. What NOT to do
+
+- **Never import `github.com/glebarez/*` or `gorm.io/driver/sqlite`:**
+ glebarez re-registers modernc's `sqlite` driver name, so the binary
+ panics at init; the gorm.io one is cgo. `db.Open` already wires
+ `gormlite` over modernc.
+- **Your app's components stay in your app.** `ui` is rastrillo's
+ vocabulary, not yours. The day one app copies
+ another's, extract it to a shared module that week and delete
+ the copy. A copy is the trigger β€” "a second consumer appears" never
+ fires.
+- **UI: the vocabulary is attributes, not classes.** `<div rst-box>`,
+ `<a rst-btn="primary">`, `<div rst-callout-body>`; `class` carries
+ only `rst-sr-only`, `rst-mono`, `rst-m-hide`, `rst-grow`, `rst-nm`,
+ `rst-danger`, `rst-cell-mut`. `rastrillo markup --fix` converts an app
+ written the old way. `rst-list`/`rst-card` hold rows only (unpadded
+ by design).
+ Forms, prose, links go in `rst-box` with a sibling `rst-box-head`.
+ **A labelled control is never hand-rolled**: `field-text`,
+ `field-textarea`, `field-select` (and the date kinds) inside `<form
+ rst-form>`, closed by `form-foot`. A hand-written `<label>Email
+ <input></label>` validates fine and renders as ragged inline labels
+ over an unstyled button β€” nothing fails, so nothing tells you.
+ Button sizes `sm`/default/`lg` compose with the variant: a submit is
+ `rst-btn="primary lg"` (form-foot writes it), never the toolbar-sized
+ default stretched across the column. docs/site/forms.md
+ Screens stack vertically β€” never heading, paragraph and button in one
+ flex row; a notice with a CTA is a `callout` ending in a link. State
+ is never colour alone: a `status-pill`'s label and a `meter`'s
+ fraction are always visible text; `Alert` (`role="alert"`) is for
+ live problems, not ambient notes. A dashboard opens with `rst-stats`
+ holding `stat` cells, one `rst-stat="lead"`; there is no separate
+ headline component. Put the sign in `Delta` and pass `Tone` yourself β€”
+ a fall is good news about half the time.
+ A name inline with other content needs `<bdi>`, or an RTL name draws
+ the number beside it to its LEFT. Group a QUANTITY's digits for the
+ locale; never an identifier, year or version β€” order 4471, not 4,471.
+ `detail-list` takes `DateTime` beside `Value` for a moment (`<time>`). Every menu is a
+ `<details name="rst-menus">` (opening one closes the rest);
+ `rastrillo.js` closes any on outside click or Escape; `MenuGroup`
+ names another group, and a nested `rst-menu-group` MUST name a
+ different one or it closes its parent. Full vocabulary:
+ rastrillo.org/design-system (built from `ui` by `cmd/dsgen`, not
+ committed); `go generate ./...` renders a local copy into
+ `.design-system/`. docs/site/templates.md
+- **Modern CSS is the floor, not a hazard.** `tokens.css` plus a shipped
+ theme already require Chrome 123, Safari 17.5, Firefox 121 β€” none older
+ than late 2023 β€” because every theme colour is a `light-dark()`. Reach
+ for the modern feature: anything that shipped at or below that floor is
+ free, `oklch()` and `color-mix()` included, with no hex twin and no
+ `@supports`. A hex fallback protects nobody anyway β€” an engine too old
+ for `oklch()` dropped the whole `light-dark()` palette several
+ declarations earlier. And self-contained means the CSS fetches nothing:
+ no imports, no remote assets, no webfont, held by `ui_test.go`. It has
+ never meant old engines. Go above the floor with `@supports`, or move
+ the floor and say so. The bar for adopting something newer is a year in
+ all three engines; `cssfloor_test.go` fails once the floor has gone nine
+ months unreviewed.
+ docs/site/templates.md
+- **Never hand-roll an error page.** `view.Fail`/`NotFound`/`Forbidden`
+ render styled pages inside the shell; a 500 shows a ref matching the
+ log line's `ref`. Wire `opts.ErrorPage` (and `Ctx.ErrorPage`) to a
+ `rastrillo.ErrorPageFunc` β€” `rastrillo new` scaffolds it as
+ `render.go`'s `ErrorPage` over `templates/errors.html`; panics recover
+ there. Unwired, errors are bare text.
+ docs/site/templates.md
+- **Never `git merge` to main**, even locally: every change lands
+ through your forge's review flow, on its own branch.
+
+## Checklist before you call an app done
+
+1. Every handler on an owned model goes through the `owned(r)` seam.
+2. Every update names its columns in `.Select(...)`.
+3. `csrf.Protect(origin)` mounted app-wide, above the route groups.
+4. Signed-in routes, jobs' included, in a `Group` with `sess.Require`.
+5. One `migrate.Apply` at boot; `make ci` runs `rastrillo migration check`.
+6. `opts.DBPath` blanked before `Serve` when the app opened its own handle.
+7. Not-found and not-yours both answer 404.
+8. No screen carries two ways to start work; each action has its own page.