chore: bakeoff repo, brief, contract and shared TodoMVC assets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 233cace7d215d541e21a892b670c0f6544b50276 parent 26d8156
20 files changed, +3898 −0
  • .gitignore +1 −0
  • AGENTS.md +37 −0
  • BRIEF.md +143 −0
  • CONTRACT.md +105 −0
  • apps/.keep +0 −0
  • results/.keep +0 −0
  • shared/base.css +141 −0
  • shared/index.css +393 −0
  • shared/reference.html +68 −0
  • tests/behaviour/package-lock.json +76 −0
  • tests/behaviour/package.json +7 −0
  • vendor/carlos-skills/SOURCE +1 −0
  • vendor/carlos-skills/building-carlos-apps/SKILL.md +329 −0
  • vendor/carlos-skills/building-carlos-apps/references/blueprint.md +267 −0
  • vendor/carlos-skills/building-carlos-apps/references/decisions.md +227 −0
  • vendor/carlos-skills/building-carlos-apps/references/platform.md +870 −0
  • vendor/carlos-skills/building-carlos-apps/references/process.md +192 −0
  • vendor/carlos-skills/building-carlos-apps/references/rastrillo.md +218 −0
  • vendor/carlos-skills/building-carlos-apps/references/tenancy.md +360 −0
  • vendor/carlos-skills/getting-started/SKILL.md +463 −0
diff --git a/.gitignore b/.gitignore
index dc524b6..235c0f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ tests/behaviour/playwright-report/
*.db
*.db-shm
*.db-wal
+.cache/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..fad0648
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,37 @@
+# ๐Ÿค– AGENTS.md: TodoMVC bakeoff
+
+The one rule: **every app gets the same chance.** The same brief, the
+same contract, the same UI, the same tests, the same measurement, the
+same review-and-fix round, and the same platform defaults. Any difference
+in how one app is treated is a defect in the bakeoff, not a detail.
+
+Design: `docs/superpowers/specs/2026-10-23-todomvc-bakeoff-design.md`.
+Plan: `docs/superpowers/plans/2026-10-23-todomvc-bakeoff.md`.
+
+## Layout
+
+| Path | What |
+|---|---|
+| `BRIEF.md`, `CONTRACT.md` | What every builder gets. Binding |
+| `shared/` | TodoMVC CSS (`todomvc-common` 1.0.5, `todomvc-app-css` 2.4.3) and the reference page |
+| `vendor/carlos-skills/` | The CARLOS skills builders read, pinned (see `SOURCE`) |
+| `tests/behaviour/` | Playwright suite, run with JS on and off |
+| `harness/` | Go measurement harness and report generator; `harness/refapp` is a test fixture builders never see |
+| `builders/` | Prompts, runner scripts, session logs and reviews |
+| `apps/<slug>/` | One directory per contestant, written only by its builder |
+| `results/` | One JSON file per app, written only by the harness |
+| `report/site/` | The generated report page |
+
+## Rules
+
+- **Never fix an app by hand.** Apps change only through their builder's
+ session (the build, the one fix round, and the security-only round).
+ A pipeline problem is fixed in the scripts or the brief, and then every
+ affected app is rebuilt.
+- **Never work around Carloku.** A missing capability becomes a Feature
+ Request on amadan (`amadan discuss new carlos/platform`), and the report
+ names it.
+- **The report doesn't crown a winner.** Numbers, context and caveats.
+- Servers bind `127.0.0.1` only. Scratch goes in `$TMPDIR`. Don't change
+ `GOCACHE`. No `sudo`. Stop what you start.
+- Commits end with a `Co-Authored-By:` trailer naming the model.
diff --git a/BRIEF.md b/BRIEF.md
new file mode 100644
index 0000000..17148fc
--- /dev/null
+++ b/BRIEF.md
@@ -0,0 +1,143 @@
+# ๐Ÿค– Builder brief: TodoMVC on CARLOS
+
+You're building one entry in a bakeoff. The same TodoMVC app is built in
+nine language setups and deployed to Carloku, and the results are
+compared on a public report page. The point is to show that CARLOS apps,
+and the CARLOS Platform, aren't tied to Go. It isn't a race to crown a
+winner, so write the app the way an experienced developer in your
+language would write it well the first time.
+
+Your setup, your slug and anything specific to it are in the prompt that
+handed you this brief.
+
+## Read first
+
+1. `CONTRACT.md`: the routes, forms and markup every app shares. It's
+ binding.
+2. `shared/reference.html`, `shared/base.css`, `shared/index.css`: the
+ TodoMVC page and styles you serve.
+3. `vendor/carlos-skills/getting-started/SKILL.md` and
+ `vendor/carlos-skills/building-carlos-apps/SKILL.md` with
+ `references/platform.md`: what a CARLOS app is and how the `carlos`
+ CLI deploys one. Read them for the platform. Where they assume Go,
+ Rastrillo, amadan hosting, canary reviews or Rastrillo's UI, this
+ brief overrides them.
+4. The TodoMVC app spec, adapted by the contract:
+ [https://github.com/tastejs/todomvc/blob/master/app-spec.md](https://github.com/tastejs/todomvc/blob/master/app-spec.md).
+ The server holds the data in SQLite, not `localStorage`, and the
+ routes are real paths, not `#/`.
+
+## What to build
+
+A server-rendered TodoMVC that works fully with JavaScript turned off,
+with a small amount of JavaScript layered on top so it behaves like the
+reference TodoMVC when JavaScript is on. There's no client framework and
+no JS build step. Plain ES modules are fine, and so is vendored VanJS.
+
+You choose the stack: HTTP server, router, templating, SQLite binding,
+and anything else, as your best recommendation for this language. Explain
+the choices in your README.
+
+## The platform contract
+
+- One binary for **linux-arm64**, started as
+ `<bin> --socket <path> --db <path>`. It listens on that unix socket.
+ There is no `$PORT`.
+- For local runs and tests it also accepts `--listen 127.0.0.1:<port>`
+ in place of `--socket`. Never bind anything other than `127.0.0.1`.
+- It serves `GET /healthz` and `GET /api/version` (see `CONTRACT.md`).
+- When it needs its own hostname, it reads `CARLOS_HOST`. It never parses
+ the socket path.
+- **Linked statically, or depending on nothing but libc.** Instances run
+ sandboxed and see only their own build, so don't count on any other
+ shared library being there.
+- SQLite in WAL mode, one file at `--db`. Migrations only ever add; never
+ drop or rewrite data.
+- Starts fast (instances sleep when idle and wake on the next request)
+ and exits cleanly on SIGTERM.
+- Don't set any memory, CPU or tier fields. Every app runs at the
+ platform default.
+
+## Your directory
+
+Work only in `apps/<slug>/`. Don't read other apps' directories or the
+harness source. Everything else in the repo is shared and read-only to
+you.
+
+Write these files:
+
+- `AGENTS.md`, **first, before code.** It holds the one rule this app
+ lives by, and the tenancy declaration: one instance for the whole app,
+ each visitor's list kept separately by an anonymous cookie, no
+ sign-in, trust class C (the todo text is stored as plain text; that's
+ chosen and written down here).
+- `README.md`: the stack you chose and why, and how to build, run, test
+ and deploy.
+- `DEPS`: one direct third-party dependency per line. Leave the file
+ empty if there are none.
+- `Makefile` with these targets:
+
+| Target | Does |
+|---|---|
+| `make build` | Builds for this machine into `dist/app` |
+| `make build-arm64` | Builds the shipped binary into `dist/app-linux-arm64` |
+| `make test` | Runs your own tests |
+| `make clean` | Removes build output only. Leaves dependency caches alone |
+| `make clean-deps` | Also removes every compiled third-party artifact, but keeps downloaded sources, so the next `make build-arm64` compiles every library from scratch. If your toolchain keeps compiled libraries in a shared per-user store (cabal, opam, zig, swiftpm and so on), point that store inside `apps/<slug>/.cache/` from the Makefile so this target can delete it without touching anything else. **Go is the exception:** never change `GOCACHE`. Instead, `make build-arm64 COLD=1` passes `-a` |
+| `make bump` / `make unbump` | `bump` changes the text of one string constant in the file that holds the `GET /` handler, so the next build must recompile that file. `unbump` puts it back |
+| `make deploy` | Deploys `dist/app-linux-arm64` with `carlos deploy` (see below) and needs no prompts |
+
+- `STATUS.md`, written last. Its first line is exactly one of:
+ - `LIVE https://todo-<slug>.bfj.oncarlos.com <version>`
+ - `BLOCKED <one-line reason>`, followed by a section headed
+ `## Where it got stuck`.
+
+ Use `BLOCKED needs-arm64-host` only when the app builds and passes
+ locally but you can't produce a linux-arm64 binary on this machine.
+ Use `BLOCKED platform: <what's missing>` when Carloku lacks something
+ the app needs. Don't work around the platform.
+
+`.cache/`, `dist/` and database files are git-ignored.
+
+## Testing
+
+- Your own tests, in whatever form is normal for your language.
+- The shared behaviour suite. Run your app with
+ `dist/app --listen 127.0.0.1:<port> --db $TMPDIR/<slug>.db`, then run
+ `scripts/test-app.sh http://127.0.0.1:<port> $TMPDIR/<slug>-suite.json`.
+ It runs every test with JavaScript on and again with it off. It must
+ pass before you deploy.
+- `qemu-aarch64-static` is on `PATH` (see `docs/toolchains.md`) if you
+ want to run the arm64 binary locally first.
+
+## Deploying to Carloku
+
+Account `bfj`, app `todo-<slug>`, host `todo-<slug>.bfj.oncarlos.com`.
+The first time:
+
+```
+carlos apps create --account bfj --app todo-<slug> --build-probe /api/version
+carlos instances enable --account bfj --app todo-<slug>
+carlos instances create --account bfj --app todo-<slug> --host todo-<slug>.bfj.oncarlos.com --channel edge
+```
+
+After that, `make deploy` runs
+`carlos deploy --account bfj --app todo-<slug> --target linux-arm64 --message "<what changed>" dist/app-linux-arm64`.
+The version is the git short SHA of your commit. Before the first
+deploy, run `carlos vet --boot` on the shipped version as
+`references/platform.md` describes. Then run the shared suite against
+`https://todo-<slug>.bfj.oncarlos.com`. It must pass there too.
+
+If a `carlos` command refuses or behaves unexpectedly, read its `-h` and
+`references/platform.md` before trying something else.
+
+## Git
+
+Commit on your branch (`app/<slug>`) as you go, with clear messages
+ending in `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
+Don't push and don't merge.
+
+## Limits
+
+You have 4 hours of wall-clock time. If you're stuck before then, stop,
+write `BLOCKED` in `STATUS.md` with where you got stuck, and end.
diff --git a/CONTRACT.md b/CONTRACT.md
new file mode 100644
index 0000000..271e69b
--- /dev/null
+++ b/CONTRACT.md
@@ -0,0 +1,105 @@
+# ๐Ÿค– The bakeoff contract
+
+Every app in the bakeoff answers the same requests the same way, so one
+test suite and one measurement harness can drive all of them. This file
+is binding. If something here seems wrong, say so in your `STATUS.md`
+rather than deviating.
+
+## Pages
+
+| Request | Response |
+|---|---|
+| `GET /` | The whole list |
+| `GET /active` | Only items not completed |
+| `GET /completed` | Only completed items |
+| `GET /<filter>?edit=<id>` | The same page, with that item's `li` given the class `editing` and its `input.edit` inside a form that posts to `/todos/<id>/edit` |
+| `GET /static/base.css`, `GET /static/index.css` | `shared/base.css` and `shared/index.css`, byte for byte |
+| `GET /healthz` | 200 |
+| `GET /api/version` | 200, the body is the build version |
+
+The filter links are `href="/"`, `href="/active"` and `href="/completed"`,
+and the current one has the class `selected`. `#/` routes aren't used.
+
+## Actions
+
+Every action is an HTML form POST (`application/x-www-form-urlencoded`).
+Without any special request headers, each one answers **303 See Other**
+back to the current filter.
+
+| Request | Fields | Effect |
+|---|---|---|
+| `POST /todos` | `title`, `filter` | Adds an item. The title is trimmed; if nothing is left, nothing is added |
+| `POST /todos/<id>/toggle` | `filter` | Flips that item between active and completed |
+| `POST /todos/<id>/edit` | `title`, `filter` | Renames it. The title is trimmed; if nothing is left, the item is deleted |
+| `POST /todos/<id>/destroy` | `filter` | Deletes it |
+| `POST /todos/toggle-all` | `filter` | Marks every item completed. If every item already was, marks them all active |
+| `POST /todos/clear-completed` | `filter` | Deletes every completed item |
+
+- `filter` is `all`, `active` or `completed`. The 303 goes to `/`,
+ `/active` or `/completed` to match. A missing or unknown value goes
+ to `/`.
+- A POST naming an `<id>` that isn't in the caller's own list answers
+ **404**. Nothing changes, in that list or anyone else's.
+- A POST with an `Origin` header that isn't this app's own origin
+ answers **403**. The app's own origin is `https://$CARLOS_HOST` on
+ Carloku, and `http://<listen address>` when run with `--listen`. A
+ POST with no `Origin` header is allowed. There's no CSRF token.
+- Your JavaScript may call these same endpoints with a request header of
+ your choosing (for example `Accept: application/json` or a custom
+ header) and get HTML fragments or JSON back. Without that header, the
+ behaviour above must not change.
+
+## Markup
+
+Start from `shared/reference.html`, which is TodoMVC's own template.
+With JavaScript on, your page must look like it. The suite compares
+screenshots, masking only `footer.info`.
+
+- Every `li` in `ul.todo-list` carries `data-id="<id>"`, has the class
+ `completed` when completed, and has `editing` while being edited.
+- `section.main` and `footer.footer` are hidden when the list is empty
+ (all filters considered). `button.clear-completed` is hidden when
+ nothing is completed.
+- The counter reads `<strong>N</strong> item left` or
+ `<strong>N</strong> items left`.
+- The footer line "Created by โ€ฆ" reads
+ `Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> ยท <LANGUAGE>`,
+ where `<LANGUAGE>` is your setup's name, e.g. `Rust` or
+ `Go + Rastrillo`.
+
+### Controls that must work without JavaScript
+
+The suite drives a page with JavaScript off through exactly these:
+
+| Action | Control |
+|---|---|
+| Add | `input.new-todo` in a form posting to `/todos`. Pressing Enter submits it |
+| Toggle one | A submit control matching `[data-action="toggle"]` inside that item's `li`, in a form posting to `/todos/<id>/toggle` |
+| Edit | A link `a[data-action="edit"]` inside that item's `li`, going to `?edit=<id>` on the current filter. The edit view's `input.edit` submits on Enter |
+| Delete | `button.destroy`, a submit button in a form posting to `/todos/<id>/destroy` |
+| Toggle all | A submit control matching `[data-action="toggle-all"]`, in a form posting to `/todos/toggle-all` |
+| Clear completed | `button.clear-completed`, a submit button in a form posting to `/todos/clear-completed` |
+
+These extra controls may be visible when JavaScript is off, styled to
+fit. When JavaScript is on, the page must match the reference, so hide
+or replace them.
+
+### With JavaScript on
+
+The page behaves like TodoMVC's own apps. Double-click a label to edit.
+Enter or blur saves, and Escape cancels. An empty title deletes. Clicking
+`input.toggle` or `input.toggle-all` takes effect without a full page
+load, and the counter, filters, "Clear completed" button and toggle-all
+state stay correct. `input.new-todo` is cleared after adding.
+
+## Lists
+
+- The whole app is one instance. Each visitor gets their own list through
+ a cookie set on their first visit. The cookie holds a random value with
+ at least 128 bits of randomness and is `HttpOnly; SameSite=Lax;
+ Path=/`, plus `Secure` when served over https.
+- A cookie value the app doesn't recognise, garbled or not, gets a fresh
+ empty list, never an error.
+- Titles are shown as text, never as HTML. Any Unicode is kept exactly as
+ typed, and titles up to at least 10,000 characters work.
+- `Content-Length`, when sent, is the number of bytes in the body.
diff --git a/apps/.keep b/apps/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/results/.keep b/results/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/shared/base.css b/shared/base.css
new file mode 100644
index 0000000..da65968
--- /dev/null
+++ b/shared/base.css
@@ -0,0 +1,141 @@
+hr {
+ margin: 20px 0;
+ border: 0;
+ border-top: 1px dashed #c5c5c5;
+ border-bottom: 1px dashed #f7f7f7;
+}
+
+.learn a {
+ font-weight: normal;
+ text-decoration: none;
+ color: #b83f45;
+}
+
+.learn a:hover {
+ text-decoration: underline;
+ color: #787e7e;
+}
+
+.learn h3,
+.learn h4,
+.learn h5 {
+ margin: 10px 0;
+ font-weight: 500;
+ line-height: 1.2;
+ color: #000;
+}
+
+.learn h3 {
+ font-size: 24px;
+}
+
+.learn h4 {
+ font-size: 18px;
+}
+
+.learn h5 {
+ margin-bottom: 0;
+ font-size: 14px;
+}
+
+.learn ul {
+ padding: 0;
+ margin: 0 0 30px 25px;
+}
+
+.learn li {
+ line-height: 20px;
+}
+
+.learn p {
+ font-size: 15px;
+ font-weight: 300;
+ line-height: 1.3;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+#issue-count {
+ display: none;
+}
+
+.quote {
+ border: none;
+ margin: 20px 0 60px 0;
+}
+
+.quote p {
+ font-style: italic;
+}
+
+.quote p:before {
+ content: 'โ€œ';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ top: -20px;
+ left: 3px;
+}
+
+.quote p:after {
+ content: 'โ€';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ bottom: -42px;
+ right: 3px;
+}
+
+.quote footer {
+ position: absolute;
+ bottom: -40px;
+ right: 0;
+}
+
+.quote footer img {
+ border-radius: 3px;
+}
+
+.quote footer a {
+ margin-left: 5px;
+ vertical-align: middle;
+}
+
+.speech-bubble {
+ position: relative;
+ padding: 10px;
+ background: rgba(0, 0, 0, .04);
+ border-radius: 5px;
+}
+
+.speech-bubble:after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ right: 30px;
+ border: 13px solid transparent;
+ border-top-color: rgba(0, 0, 0, .04);
+}
+
+.learn-bar > .learn {
+ position: absolute;
+ width: 272px;
+ top: 8px;
+ left: -300px;
+ padding: 10px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, .6);
+ transition-property: left;
+ transition-duration: 500ms;
+}
+
+@media (min-width: 899px) {
+ .learn-bar {
+ width: auto;
+ padding-left: 300px;
+ }
+
+ .learn-bar > .learn {
+ left: 8px;
+ }
+}
diff --git a/shared/index.css b/shared/index.css
new file mode 100644
index 0000000..2c0b4b6
--- /dev/null
+++ b/shared/index.css
@@ -0,0 +1,393 @@
+@charset 'utf-8';
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #111111;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 300;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -140px;
+ width: 100%;
+ font-size: 80px;
+ font-weight: 200;
+ text-align: center;
+ color: #b83f45;
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ height: 65px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+.toggle-all {
+ width: 1px;
+ height: 1px;
+ border: none; /* Mobile Safari */
+ opacity: 0;
+ position: absolute;
+ right: 100%;
+ bottom: 100%;
+}
+
+.toggle-all + label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ position: absolute;
+ top: -65px;
+ left: -0;
+}
+
+.toggle-all + label:before {
+ content: 'โฏ';
+ display: inline-block;
+ font-size: 22px;
+ color: #949494;
+ padding: 10px 27px 10px 27px;
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+
+.toggle-all:checked + label:before {
+ color: #484848;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none; /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle + label {
+ /*
+ Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
+ IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
+ */
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked + label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.todo-list li label {
+ overflow-wrap: break-word;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+ font-weight: 400;
+ color: #484848;
+}
+
+.todo-list li.completed label {
+ color: #949494;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #949494;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover,
+.todo-list li .destroy:focus {
+ color: #C18585;
+}
+
+.todo-list li .destroy:after {
+ content: 'ร—';
+ display: block;
+ height: 100%;
+ line-height: 1.1;
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ font-size: 15px;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: #DB7676;
+}
+
+.filters li a.selected {
+ border-color: #CE4646;
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 19px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #4d4d4d;
+ font-size: 11px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/*
+ Hack to remove background from Mobile Safari.
+ Can't use it globally since it destroys checkboxes in Firefox
+*/
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
+
+:focus,
+.toggle:focus + label,
+.toggle-all:focus + label {
+ box-shadow: 0 0 2px 2px #CF7D7D;
+ outline: 0;
+}
diff --git a/shared/reference.html b/shared/reference.html
new file mode 100644
index 0000000..d2b3622
--- /dev/null
+++ b/shared/reference.html
@@ -0,0 +1,68 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>TodoMVC</title>
+ <link rel="stylesheet" href="base.css">
+ <link rel="stylesheet" href="index.css">
+ </head>
+ <body>
+ <section class="todoapp">
+ <header class="header">
+ <h1>todos</h1>
+ <input class="new-todo" placeholder="What needs to be done?" autofocus>
+ </header>
+ <section class="main">
+ <input id="toggle-all" class="toggle-all" type="checkbox">
+ <label for="toggle-all">Mark all as complete</label>
+ <ul class="todo-list">
+ <li class="completed" data-id="1">
+ <div class="view">
+ <input class="toggle" type="checkbox" checked>
+ <label>Buy milk</label>
+ <button class="destroy"></button>
+ </div>
+ <input class="edit" value="Buy milk">
+ </li>
+ <li data-id="2">
+ <div class="view">
+ <input class="toggle" type="checkbox">
+ <label>Walk the dog</label>
+ <button class="destroy"></button>
+ </div>
+ <input class="edit" value="Walk the dog">
+ </li>
+ <li data-id="3">
+ <div class="view">
+ <input class="toggle" type="checkbox">
+ <label>Write the report</label>
+ <button class="destroy"></button>
+ </div>
+ <input class="edit" value="Write the report">
+ </li>
+ </ul>
+ </section>
+ <footer class="footer">
+ <span class="todo-count"><strong>2</strong> items left</span>
+ <ul class="filters">
+ <li>
+ <a class="selected" href="/">All</a>
+ </li>
+ <li>
+ <a href="/active">Active</a>
+ </li>
+ <li>
+ <a href="/completed">Completed</a>
+ </li>
+ </ul>
+ <button class="clear-completed">Clear completed</button>
+ </footer>
+ </section>
+ <footer class="info">
+ <p>Double-click to edit a todo</p>
+ <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> ยท LANGUAGE</p>
+ <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+ </footer>
+ </body>
+</html>
diff --git a/tests/behaviour/package-lock.json b/tests/behaviour/package-lock.json
new file mode 100644
index 0000000..d518b00
--- /dev/null
+++ b/tests/behaviour/package-lock.json
@@ -0,0 +1,76 @@
+{
+ "name": "bakeoff-behaviour",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "bakeoff-behaviour",
+ "devDependencies": {
+ "@playwright/test": "1.62.1"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/tests/behaviour/package.json b/tests/behaviour/package.json
new file mode 100644
index 0000000..1281cd1
--- /dev/null
+++ b/tests/behaviour/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "bakeoff-behaviour",
+ "private": true,
+ "type": "module",
+ "scripts": { "test": "playwright test" },
+ "devDependencies": { "@playwright/test": "1.62.1" }
+}
diff --git a/vendor/carlos-skills/SOURCE b/vendor/carlos-skills/SOURCE
new file mode 100644
index 0000000..0af541f
--- /dev/null
+++ b/vendor/carlos-skills/SOURCE
@@ -0,0 +1 @@
+carlosframework/skills @ e92307c11f5b77f879fe9c118748c90e9a5b298e
diff --git a/vendor/carlos-skills/building-carlos-apps/SKILL.md b/vendor/carlos-skills/building-carlos-apps/SKILL.md
new file mode 100644
index 0000000..6d1631a
--- /dev/null
+++ b/vendor/carlos-skills/building-carlos-apps/SKILL.md
@@ -0,0 +1,329 @@
+---
+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, deciding what one instance is (per person, team, customer, repo, or one shared instance; a signup that creates instances; a home or hub; reporting across tenants; SaaS customers who want on-prem), choosing a trust class (S, A1, A2, A/pub, B, C), or weighing the family's other open choices (app shape, hosting, identity). 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 tenant (own process, own SQLite file, own unix
+socket), many instances on one small box behind a small router**, with
+every database continuously replicated to object storage and idle
+instances hibernating to it. What a tenant *is* (a person, a team, a
+customer company, a repository, a chat, or the whole app) is the app's
+first decision.
+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: tenancy, trust class, app shape, hosting, identity.
+- Working out what one instance of an app should be, how people reach it,
+ what the host can see, and which CARLOS benefits that earns.
+- 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 every decision
+pre-answered with the defaults (one instance, class C declared, server
+shape, Carloku).
+
+## Start here: tenancy, then trust
+
+Before stack, screens or deploys, answer three questions and write the
+answers down as a **tenancy declaration** in AGENTS.md. Everything else
+in this skill, and every benefit on carlosframework.com, hangs off them.
+
+1. **What is one instance?** A person (Kass), a chat (Eleven), a mailbox
+ (Keymail), a team (Oficina, an internal finance ledger), a customer company (Tito), a
+ community (Woodstar), a repository (amadan), a bank connection,
+ the whole app (Movement), or nothing at all (a static site). The
+ boundary is the app's choice; there is no default. Choose the smallest
+ unit that owns data together, fails together and leaves together.
+ Sharing is membership in someone's instance, never a copy. Never
+ tenancy by a `tenant_id` column in one shared file. Totals across
+ tenants come from facts the centre owns or witnesses and fixed-shape
+ records instances push, never from opening tenant databases.
+2. **How do people reach it?** One instance with in-app accounts, a
+ simple home that lists your instances, a hub that puts every tenant
+ under one domain, or a blind home for end-to-end encrypted apps.
+ Central apps are stateless: records in object storage, no database.
+3. **What can the host see?** A trust class per workload: S (no user
+ data), A1/A2/A-pub (host-blind, with declared residue or public
+ surfaces), B (sealed at rest, readable while awake), C (plaintext,
+ chosen and written down).
+
+Then name the benefits the design earns, each with its mechanism (isolated
+data per tenant, blast radius of one, hibernation, per-tenant export and
+erasure, residency once switched on, a falsifiable security claim, self-hosting from the same
+binary), and the costs it accepts. **[references/tenancy.md](references/tenancy.md)**
+has the gallery's real choices, the four user models, the classes, the
+benefits table with what is live today, worked examples, and the
+declaration template. Read it first.
+
+## Where this sits now (2026-10-15)
+
+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 โ€” and since late August
+ so are **canaries** (`carlos canary`), **timetables**
+ (`carlos schedule`), **errors and analytics** counted at the edge with
+ no SDK, a **password gate** in front of a hostname, **feature flags**,
+ and **latency DNS steering** โ€” and since September, **server-held
+ credentials** (`carlos services`) so a signup flow can create a
+ customer's instance itself, **path routes** so one hostname can serve
+ each customer from their own instance, a **STUN/TURN relay**
+ (`carlos relay`), and **self-serve object storage** (apply once per
+ account, then `carlos store create`). Reach for a platform verb before
+ building any of them into an app.
+ **[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 (its price list is not published yet,
+ 2026-10-18; don't quote prices). Carloku is the product
+ brand; the CLI is always `carlos`.
+- **Rastrillo** is the CARLOS web framework (v0.28.0; module and
+ canonical repo `amadan.net/rastrillo/rastrillo`, GitHub a mirror): 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,
+ keyring, webauthn, eventlog, blobs, mail, `assertion` for signed
+ cross-origin handoffs, `vault`, `pow`, agent tools), plus the `idear`
+ module for roles and invitations โ€” 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: settle tenancy first (above), 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.
+
+## UI default: Rastrillo with a thin CSS layer
+
+For new app screens, start with Rastrillo's design system: its `ui`
+partials, attribute vocabulary (`rst-box`, `rst-btn="primary"`), design
+tokens and themes. Read
+[the styling guidance](references/rastrillo.md#design-system-and-app-css)
+before writing templates or CSS. Keep app CSS small: app-specific layout,
+branding through token overrides, and components the library does not yet
+provide. Reuse the shared controls instead of rebuilding them or overriding
+them screen by screen.
+
+This is the default, not a request to redesign an existing app. Respect an
+explicit design brief or an established design system; record the reason
+when choosing a different foundation.
+
+## 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 Slopbox
+(A1) through Keymail's sealed-content-plaintext-envelopes (A2) and
+Woodstar's split-by-content-class (A2 + A/pub) to an internal finance ledger's sealed
+single-tenant (B) and Tito's deliberate server trust (C), 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) |
+| Periodic work | `carlos schedule` โ€” a time and a path; each fire is a POST to a route the app already serves, over its own socket. Never cron, never a second worker. A schedule tighter than the idle window stops the instance hibernating, and that is billed โ€” platform.md |
+| Object storage | Once per account, `carlos store apply` and a deployment operator approves; then `carlos store create` declares a bucket and credentials arrive as env; `carlos store scan` is opt-in virus scanning, off by default. It reconciles โ€” it counts the objects it has NOT looked at โ€” so ask for both numbers. Server-blindness wins over it: a client-encrypted store gets no coverage claim, because nothing can read the bytes โ€” platform.md |
+| Errors and analytics | `carlos errors` needs no wiring and collects from day one (tagged log lines; edge-seen 5xx are `carlos availability`); `carlos analytics` counts at the edge with no JavaScript, no cookie, no stored IP, and is opt-in per app or account. Do not add a third-party analytics script to a CARLOS app โ€” platform.md |
+| Canaries | `carlos canary` โ€” a branch-only channel and hostname, outside every pipeline, seven-day lease. Review there, never on localhost. Pass `--environment` if the app carries config, or every form submission on the canary host fails โ€” platform.md |
+| Config environments | `carlos env --environment` writes a named bundle; a route is bound to one via the instance record (`instances set-environment`), then `carlos restart --environment` cycles exactly what reads it โ€” platform.md |
+| Feature flags | `carlos features set` โ€” the platform serves them, only the app interprets them, and they land on the instance's next poll with no restart โ€” platform.md |
+| Hostname-level password | `carlos gate` โ€” one shared password at the edge in front of an app's hostnames, for a prototype or a private static site. A curtain over a hostname, never a substitute for the app's own accounts โ€” platform.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 |
+| Tenancy | The app chooses what one instance is; the declaration (unit, user model, class per workload, residue, benefits, costs) goes in AGENTS.md before code โ€” tenancy.md |
+| A customer per instance | The signup app holds an `instance`-role credential (`carlos services create --role instance --app <tenant-app>`) and `POST`s `/api/cli/apps/<account>/<tenant-app>/instances` โ€” customers are instances, never Carloku accounts. The credential is app-wide, so it lives on the signup app only โ€” platform.md "Provisioning instances from your own app" |
+| One hostname, many instances | Path routes: the first path segment of a claimed hostname points at another route in the same account, on the same box. One hostname is one origin โ€” platform.md "One hostname, many instances" |
+| Central apps (sign-in, directory, home) | Stateless: no SQLite of their own, records in object storage, so they run anywhere and lose nothing when moved |
+| WebRTC | `carlos relay enable` โ€” the platform's STUN/TURN, delivered as `CARLOS_RELAY`; never a third-party TURN service โ€” 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 |
+| Activity descriptions | Pass a concise `--message` on supported mutations: say what changes and why. The console records the exact target separately. Agents supply the message even when prompts are disabled โ€” platform.md |
+| Identity | "Sign in with Keymail" / passkeys+PRF / magic link+TOTP by trust model โ€” decisions.md ยง4 |
+| Trust | A class per workload, S to C; server-blind by default; every softening named and grep-tested. Placement by class is designed, not enforced โ€” tenancy.md ยง3, 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 | Code on amadan by default (its agent house rules: `amadan agent skill -install`); worktree per session, branch, regular pushed commits; merge to main (`amadan branch merge`, optionally `-squash`) 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/tenancy.md](references/tenancy.md)** โ€” read first: what
+ one instance is, across every shape in the gallery; the four user
+ models (one instance, simple home, hub, blind home); stateless central
+ apps; the trust classes S to C; the benefits each choice earns, with
+ mechanism and what is live; and the tenancy declaration to write
+ before code.
+- **[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, config bundles, canaries), the `carlos` CLI verb table,
+ deploying a static site, standing a canary beside production, scheduled
+ work (the tick contract, idempotency, one-shot timers, what a tight
+ schedule costs), errors and analytics, sending email (SMTP credentials,
+ DKIM/DMARC alignment, the credential-propagation trap), 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 |
+|---|---|
+| One shared instance with a `tenant_id` column for many customers | Isolation on CARLOS is a process and a file. A `WHERE` clause gives up per-tenant isolation, erasure, residency and hibernation, and one missed clause leaks everyone. Make the tenant an instance โ€” tenancy.md |
+| Starting to code before deciding what one instance is | The unit decides the user model, the trust class's blast radius and which benefits the app can claim. Retrofitting it is a migration across every instance. |
+| 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). |
+| Creating a Carloku account per customer, or signing up customers with a person's CLI login | Accounts are *your* tenancy on the platform; customers are instances of your app. A signup flow holds a service credential (`carlos services`) and calls the instances API โ€” platform.md |
+| Giving every tenant its own hostname because "the edge routes by Host only" | It no longer does: path routes split one claimed hostname by its first segment, within one account and one box โ€” platform.md |
+| 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/vendor/carlos-skills/building-carlos-apps/references/blueprint.md b/vendor/carlos-skills/building-carlos-apps/references/blueprint.md
new file mode 100644
index 0000000..1ec16bf
--- /dev/null
+++ b/vendor/carlos-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/vendor/carlos-skills/building-carlos-apps/references/decisions.md b/vendor/carlos-skills/building-carlos-apps/references/decisions.md
new file mode 100644
index 0000000..59635af
--- /dev/null
+++ b/vendor/carlos-skills/building-carlos-apps/references/decisions.md
@@ -0,0 +1,227 @@
+# ๐Ÿค– The decisions โ€” trust, shape, hosting, identity
+
+The four axes every CARLOS app settles on day one, after tenancy (what
+one instance is and how people reach it: tenancy.md, which comes first
+because it decides the blast radius of every answer here). Each section gives the
+family's real positions with the reasons their own docs record โ€” offer
+these as choices with trade-offs, not as one right answer. Whatever is
+chosen: **date it, attribute it, record the rejected alternative, and
+publish every deviation.** Retrofitting any of these is a migration, not
+an edit.
+
+## 1. Trust model โ€” what can the server read?
+
+The family default is server-blindness ("if the server is compromised,
+the attacker gets nothing"), but no shipped app is absolutist. The real
+spectrum is defined by **which leaks you name**: every app publishes its
+softenings as a list, and enforces the boundary with tests that grep raw
+SQLite bytes and bucket objects for plaintext. A "partial" model is
+legitimate exactly when the leak is named; it is a violation when hidden.
+
+**The positions, with their recorded reasons:**
+
+Each position below carries its trust class (S, A1, A2, A/pub, B, C;
+tenancy.md ยง3), declared per workload. Slopbox is the A1 case: padded,
+fixed-size blobs and encrypted names, so only transport shape leaks.
+
+- **E2EE content, plaintext routing metadata โ€” Keymail (A2).** Bodies and
+ subjects are ciphertext; from/to/date/folder are readable, published
+ as a trade-off ("like SMTP envelopes"). Its named softenings show what
+ honest partial looks like: inbound ordinary email is plaintext
+ *transiently in RAM* at the boundary, then sealed server-side to the
+ mailbox's public key (sealing needs only the public half); opt-in
+ imports state before running that messages pass through memory; mixed
+ sends tell the user what travels unsealed. And a trade *refused*:
+ searchable encryption rejected โ€” the client legitimately holds
+ plaintext and keys, so the index is client-side and the server learns
+ nothing new.
+- **E2EE content, blind identity, public reference data โ€” Kass (A2;
+ its agent sidecar is C).** A
+ training log is medical-adjacent, so user content is sealed โ€” but the
+ exercise database is public reference data embedded in the binary,
+ deliberately unencrypted, and the identity registry stores email only
+ as an HMAC blind index under a pepper (the readable address lives
+ solely in unclaimed sign-in links, blanked on claim). The bar its docs
+ set: *the schema could be published as the privacy policy* โ€” leak the
+ whole registry and the worst anyone learns is roughly how many people
+ signed up, and when.
+- **Split by content class โ€” Woodstar (A2 + A/pub).** Public posts are plaintext on
+ the server (they are public; encrypting them would be theater), DMs
+ are sealed end-to-end, and private state โ€” follows, petnames, drafts,
+ settings โ€” is server-blind encrypted blobs, so the server never learns
+ the social graph. Federation (ActivityPub/Bluesky) is an explicit,
+ bounded, opt-in leak of the public class only; DM bridging is a
+ permanent non-goal.
+- **Sealed single-tenant โ€” an internal finance ledger (B).** Data encrypted
+ at rest, but the instance holds the key in memory because unattended
+ bank syncs need plaintext with no human present. The README names the
+ residual risk out loud ("a compromised live host can read data") and
+ the copy never claims otherwise; the plaintext-grep test still runs,
+ with one dated, allowlisted exception. The shape for tools that must
+ act on the data autonomously.
+- **Server-side trust โ€” Tito (C).** A ticketing platform holds buyer PII in
+ plaintext already; there is no encrypted-blob requirement to inherit,
+ and a six-person support team must be able to read an email address
+ (Vicky's rule: every decision is weighed by the support load it
+ creates). What compensates is **authorization, not decryption** โ€”
+ credentials only ever on the root origin, account-authored content
+ only on the account's own host, and operator escape hatches (2FA
+ reset) that open doors without unlocking data.
+
+**Choosing:** classify the content, not the app. Private between named
+parties and the product promise is "we can't read it" โ†’ seal it and eat
+the costs (no server search, recovery becomes product surface, key loss
+is data loss by design). Public or bridged to a public network โ†’ sealing
+it is theater; spend the effort on the private classes beside it. A
+support team that must read it โ†’ server trust, argued in writing from
+support load, repaid with origin splits and authorization design. An
+agent/automation that must act on it unattended โ†’ sealed single-tenant,
+with the residual risk in the README.
+
+**Whatever you choose:** the merciless grep test from day one, the
+deviations list in the README, and โ€” server-blind apps โ€” remember the
+bucket: Litestream ships the whole SQLite file to object storage, so a
+plaintext column is a plaintext leak. The envelope mechanics for sealed
+content (HKDF purpose keys, ECDH โ†’ AES-GCM, ECIES-wrapped per-thread/
+content keys โ€” which is also how a sealed object gains a second named
+reader) are blueprint.md's crypto section; golden vectors pin every
+implementation. **Honesty note (2026-08-23): rastrillo does not yet
+properly support the E2EE use-case as an app story** โ€” its crypto and
+webauthn packages are primitives, and framework-level support is being
+worked on now. A server-blind app today hand-builds the app-side flow
+on the Keymail/Kass/Woodstar patterns; don't claim the framework does
+it.
+
+## 2. App shape โ€” server-rendered or client-owned?
+
+The family recognizes exactly two shapes; the axis is written into the
+framework's design (chosen at scaffold time in the rastrillo ui design;
+the `server` shape is what `rastrillo new` ships today, the `client`
+shape is hand-built on the Woodstar/Eleven pattern until the framework
+grows it).
+
+- **`server` (the default): server-rendered HTML, zero-JS baseline as a
+ non-negotiable.** Every screen and primary action works with scripts
+ stripped; JS is enhancement only, enforceable by a test (fetch the
+ page script-stripped, diff for every affordance) rather than a
+ browser. Interactive components are real HTML first (a dropdown is
+ `<details>/<summary>`). This is Tito, the platform console,
+ and every admin/CRUD/dashboard surface in the family.
+ It is also what hibernation likes: request/response apps sleep well.
+- **`client`: the app runs in the browser and owns its state.** Chosen
+ when the product needs what server rendering cannot do: client-held
+ keys and client-side crypto (sealing DMs, encrypting blobs, proof-of-
+ work โ€” work that must happen where the seed is), live feeds over
+ WebSocket/SSE, and reader-owned state the server never learns.
+ This is Woodstar: a hand-written no-build-step ES-module SPA, VanJS
+ (140 vendored lines) as a thin reactive shell โ€” views return
+ `{ el, update }` โ€” with local caches merged against server-blind
+ blobs for multi-device continuity. Keymail and Kass are client-shape
+ on their sealed surfaces for the same reason: E2EE content forces the
+ shape, because sealing must happen where the keys are.
+
+**The client shape's price is discipline it must self-impose**, because
+JS owns the paint. The family's evidence for why: Eleven's hand-rolled
+shell grew to 8,722 lines with one render function invoked from 31 call
+sites โ€” the bug class a reactive binding removes structurally, and the
+reason VanJS is the sanctioned default (framework-free remains
+legitimate; anything heavier than VanJS is a redesign). Woodstar keeps
+the 300-line module cap test-enforced (one coordinator module may carry
+a higher cap; caps only ratchet down), builds all DOM through a
+falsy-dropping helper (a stray `null` child renders as the literal text
+"null" โ€” a real shipped bug), gates first paint so a cold load never
+shows the page assembling, and runs three browser drives in CI because
+a JS syntax error takes the whole client down and only a real engine
+sees it.
+
+**Platform cost differs too.** A server-shape app is one route. A
+client-shape product like Woodstar is several cooperating binaries with
+in-app path fan-out (one proxy in front of its binaries; the edge's path
+routes split a hostname only by its first segment, and only onto whole
+routes โ€” platform.md), WebSocket upgrades
+and SSE proxied through two hops (verify the 101 through the real edge,
+never assume it), wildcard customer domains via ACME delegation, and a
+held socket that interacts with hibernation. All supported โ€” none free.
+
+**Choosing:** default to `server`. Move to `client` only for a product
+that genuinely needs client-side crypto, live channels, or server-blind
+reader state โ€” not because a richer client feels more modern. The zero-JS
+baseline binds server-shape surfaces; a screen that cannot honor its
+promise without JS (sealing in the browser) *is* a client-shape surface,
+and the two compose within one product: Woodstar's status page is a
+plain static app beside the SPA, and a sealed app's marketing and
+settings pages can stay server-shape beside its client-shape editor.
+
+## 3. Hosting โ€” Carloku, your boxes, or your platform
+
+- **Carloku (hosted) โ€” the default.** Zero infra: the getting-started
+ path. The pricing design is per app (baseline + instance-hours;
+ hibernating apps cost ~the baseline; egress/storage metered
+ separately; free rungs), but no price list is published yet
+ (2026-10-18): don't quote prices or promise a free tier. Nothing about
+ the other axes locks you out of moving later.
+- **Vendors: one codebase, hosted and customer-run.** With one instance
+ per customer, an on-prem request stops being a fork: the customer's
+ instance runs on their boxes and bucket (a customer fleet) or their own
+ platform, following your release channel. Tito proves the self-hosted
+ half in three regions; no vendor has yet shipped to customers this way
+ (Jelly is in development). Publish a licence class (L1 to L5,
+ tenancy.md ยง3) so customers know what they may run.
+- **Customer fleets โ€” your boxes, the hosted control plane.** `carlos
+ 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/vendor/carlos-skills/building-carlos-apps/references/platform.md b/vendor/carlos-skills/building-carlos-apps/references/platform.md
new file mode 100644
index 0000000..96c466f
--- /dev/null
+++ b/vendor/carlos-skills/building-carlos-apps/references/platform.md
@@ -0,0 +1,870 @@
+# ๐Ÿค– 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-10-18, describing what Carloku serves: platform
+v0.28.0, released 2026-10-17. Verbs are stable, flag details evolve โ€” trust
+`carlos <verb> -h` over this file, and remember a CLI can offer a flag the
+console you are talking to has switched off or does not have yet
+("Switched off, or not released" at the end). 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).
+
+**Flags are written `--flag`.** Single-dash forms still parse and always
+will, but as of 2026-08-27 the documented face of every verb is the
+double dash, and the aliases are `-a` (`--app`), `-c` (`--console`), `-e`
+(`--environment`), `-v` (`--version`). Three flags turn up nearly
+everywhere and are not repeated below: `--app`, `--account` (or
+`--account-id` when you want a sqid matched as a sqid and nothing else),
+and `--console`, which you need only when this terminal is logged in to
+more than one deployment.
+
+## 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. A
+ hibernating route's rung is tunable (`stream` โ€” the default, about once
+ a second โ€” / `batched`, five minutes / `daily`), a loss-window trade an
+ operator makes on a large, rarely-written database.
+- **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** โ€” `carlos deploy` follows the rollout box by
+ box where boxes report it (they do on Carloku), and otherwise watches
+ the URL: the app's build probe if it declares one
+ (`carlos apps build-probe`), else the `X-Carlos-Version` header, which
+ the edge stamps from the route row the moment adoption lands, whether
+ or not the process has restarted yet.
+- **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.
+- **Errors, availability and analytics** โ€” the edge and the log stream
+ feed all three, with no SDK, no JavaScript, no cookie and no stored IP.
+ Errors and availability collect from the day an app ships; analytics is
+ opt-in. See "Watching an app" below.
+- **Password gate** โ€” `carlos gate` puts one shared password in front of
+ an app's hostnames, enforced at the edge ahead of the cache and the
+ activator, so the app behind it never sees the cookie. A curtain over a
+ hostname, not accounts for the app's own users.
+- **DNS steering** โ€” `carlos steering ... latency` opts a host into a
+ latency record per armed edge so the nearest one answers. A static site
+ on a platform hostname is steered from creation without asking; a
+ custom domain is not, because its DNS is yours.
+
+## 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`, the
+ bake bypass, is an operator act on the direct-bucket path only: the
+ console refuses it by name, so a member never has it. 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`. Every exec child is handed `CARLOS_HOST`, its
+ hostname exactly as `carlos instances list` shows it โ€” **read it, never
+ the socket's file name**, which says nothing about the host on an
+ isolated box โ€” and `CARLOS_REGION`, the placement code of the box it
+ runs on (`ie`, `au`, โ€ฆ), so an app that wants to know where it is can
+ read it rather than guess. Since v0.26.0 the platform also writes a
+ `CARLOS_INSTANCE_KEY` into the instance's own env before its first
+ start: a per-instance secret key for sealing the instance's own data.
+ It is not an API token and opens no platform door. On a box that has
+ isolated its instances (v0.25.0), each instance runs in a box of its
+ own: its own directory and namespaces, seeing only its own app's build,
+ under a memory ceiling set from the staged manifest plus CPU and IO
+ limits, and
+ with **no access to loopback or private addresses on the box** except
+ its DNS resolver โ€” so an app cannot reach a sibling over `127.0.0.1`,
+ and one that grows past its tier is OOM-killed and restarted rather
+ than allowed to take the box's other tenants with it. On such a box the
+ working directory is the instance's data directory, the one its
+ database is in, so a relative path like `./data/blobs` lands beside
+ the database. **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).
+- **Config environments (bundles)** โ€” `carlos env` / `carlos secrets`
+ write the app's **default** bundle; `--environment <name>` writes a
+ named one, layered on top of the default when config is materialized.
+ A route is *bound* to a bundle, and on a provisioned route that binding
+ lives in the instance record: `carlos instances create --environment`
+ at birth, `carlos instances set-environment` afterwards. (The box-local
+ `carlos route --environment` is reverted by the reconciler within
+ seconds and refuses such a row by name.) `carlos env environments`
+ lists an app's bundles, and `carlos restart --environment <name>`
+ cycles exactly the routes that read one.
+- **Canary** โ€” `carlos canary` stands a second instance beside production
+ on a channel and hostname belonging to one branch: `canary/<slug>` and
+ `<slug>-<app>.<sqid>.<domain>` (one label since v0.24.0, so slug and
+ app together are at most 63 characters), the slug defaulting to the git
+ branch. Promotion into a canary is never refused and costs no rung โ€” canary
+ channels sit outside every pipeline, so a build that has ridden one
+ still enters the pipeline fresh. Seven-day lease, refreshed on every
+ re-promote. It is a URL, not a traffic split.
+- **Service credential** โ€” a bearer token a *server* holds rather than a
+ person: a CI job that ships, a signup flow that creates an instance per
+ customer. `carlos services create --role <r> --app <app> <name>` mints
+ one; reach is fixed at mint and bound to one account and (with `--app`)
+ one app. Roles, narrowest first: `deploy` (for CI: ships, and promotes
+ or rolls back only the rungs named at mint โ€”
+ `services create --role deploy --app <app> --rungs canary,edge ci`,
+ never `stable`, and only `canary/<name>` channels on an app that
+ declares a pipeline; no hotfix, no app creation, so a new app's first deploy
+ still needs a person; CI sets `CARLOS_SERVICE_TOKEN` and
+ `CARLOS_CONSOLE` and runs `carlos deploy` as usual), `publish` (ship
+ releases), `instance` (create, delete, repoint and move the app's
+ instances, attach its domains, write its path routes, read its logs,
+ errors and analytics), `operate`, and `admin` (which includes minting
+ store IAM users โ€” a real handover). No role can run
+ `carlos instances enable` or mint another credential. "Provisioning
+ instances from your own app" below.
+- **Path routes** โ€” one hostname split by its first path segment:
+ `example.com/acme` served by one route, `example.com/globex` by another,
+ everything else by the hostname's own route. A prefix *points at* an
+ existing route and borrows its channel, config, hibernation and billing;
+ it defines nothing of its own. "One hostname, many instances" 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 โ€” and optionally named `destinations`, one per
+ console or app a checkout deploys to, picked with `-d <name>` on any
+ verb that takes `--console` (`carlos config destinations` edits them). 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.
+
+## Describe changes in Activity
+
+Agents pass `--message` explicitly for `ship`, `deploy`, `promote`,
+`rollback`, `restart`, `instances create`, `instances delete`, `features set`,
+`env set`, `env unset`, and `env exec-delivery`.
+Use a concise sentence describing the intended change or why it is needed:
+`carlos deploy --message "Fix invitation links after an email address changes"`.
+The console records the exact action, version, channel, or instance separately;
+repeating only a commit hash or "deploy app" adds no useful context. Do not
+invent an outcome you have not verified, or include secrets or config values.
+
+Messages are optional, one line, at most 240 Unicode characters. They are
+separate from release labels and release notes. Interactive CLI commands ask
+for a description by default; an empty answer skips it. Noninteractive commands
+do not prompt. `--no-prompt` suppresses the question for a command, and
+`"message_prompt": "off"` in `.carlos/config` disables prompts by default.
+Agents should still supply `--message` when
+prompts are disabled. Explicit `--message` still applies with `--no-prompt` or
+config suppression. `deploy` asks once and uses the same description for its
+ship and promote steps.
+
+## 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\|list\|place\|set\|build-probe\|delete\|restore` | Claim an app (a name whose platform address is already held is refused, and `tmp-` names are reserved); `list` every app, the Trash included; place it on a customer fleet; `set --swap overlap\|hold` and `--swap-concurrency` choose how its instances take a new build ("Two builds at once"); `build-probe /api/version` makes `deploy` ask the process which build it runs rather than trust the header โ€” worth setting on anything you deploy often; trash/restore |
+| `carlos deploy` | **Release new code โ€” reach for this one.** ship + promote + watch until the new build serves; zero-arg with a saved project config, `-d <name>` for a named destination. Follows the rollout box by box and names a box where the build failed. `--channel` to land somewhere other than the entry channel (a canary); `--swap hold` for a release that cannot run beside its predecessor. 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`); refuses a Go binary whose main package differs from the app's last release unless you pass `--program-change` (deploy has no such flag: ship, then promote); 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 โ€” 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. `--retry` re-tries a build that failed on some box, once you have fixed what it depended on. (`--hotfix` is refused by the console: an operator's direct-bucket act) |
+| `carlos canary` | Deploy onto a branch-only channel + hostname, waiting on the canary URL's own `X-Carlos-Version`; `ls` / `rm`. **Reach for `--environment`** if the app carries config โ€” see "Standing a canary beside production" |
+| `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\|set-channel\|set-environment\|set\|update\|retry\|door-key` | Opt an app in (`enable --private-storage` gives every later instance its own environment and bucket); declare/inspect/remove instances; reorder or remove upstreams (**adding** an upstream address is operator-only since v0.25.0); move a route onto another channel or config bundle. `list` shows INSTALLED and SERVING builds and a REASON when they differ; `retry <host>` clears a failed build's hold on one instance; `set <host> --updates manual\|--pin <build>` and `update <host>` steer how one instance takes builds; `create --role replica --region <r>` makes a replica near buyers, holding a one-way copy of the live instance's data (the live instance names allowed regions with `--replicas` at its own create); `door-key` manages keys for the edge-local door, which a box operator must turn on. `--health running\|asleep\|not-responding` narrows a listing, which is usually what you want when something is wrong. 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. `--host` narrows to one route, `--environment` to every route reading that bundle (the request that follows a `secrets set`), `--dry-run` prints the set. Sleeping instances are left asleep |
+| `carlos routes` | The app's routes through the console: upstream, channel, config environment, replication rung. It reads the console's own box, and says so on a multi-box fleet |
+| `carlos steering` | `latency` opts a host into a per-edge latency DNS record; `off` returns it to one static answer. Shared-pool instances only in v1 |
+| `carlos features set\|list` | The app's own feature flags โ€” the platform stores and serves them, only the app interprets them. Lands on the instance's next `deployment`-block poll: no restart, no env edit, no box file. `key=` clears |
+| `carlos gate set\|list\|clear` | One shared password in front of the app's hostnames, custom domains included. `set` reads stdin when `--password` is absent; changing or clearing signs every visitor out |
+| `carlos errors` | One row per distinct error, newest first โ€” `error`-tagged log lines plus the 500s the app itself answered, no wiring at all. `--fp <fingerprint>` expands a group; `--set-level warn` widens what counts |
+| `carlos availability` | Every 502, 503 and 504 the edge saw (the instance could not answer), grouped by cause, plus failed wakes and exits; one outage is one row. `--fp` expands a cause to each request. The Availability tab beside Errors |
+| `carlos analytics` | Page views, daily visitors, bytes, top pages/referers/events, counted at the edge. `--set-count edge` turns it on; `--range today\|7d\|30d` |
+| `carlos logs` | Merged app + platform + edge timeline (`-f` follows, `--grep`, `--since`) โ€” no box access |
+| `carlos db pull\|query` | A read-only copy of an instance's database, restored from its replica on your machine; the live one is never touched. `--at <RFC 3339 time>` reads it as it was then (sleeping instances keep seven days of history). `--instance` when the app has several |
+| `carlos run --app <a> --instance <host> -- <command>` | Run one of your app's own commands as that instance, with its environment and database, output streamed as it arrives (v0.28.0). `--file` uploads files with the run (64 MB in total), `--timeout` (30 minutes by default, up to 6 hours), `--follow <id>` reattaches. A sleeping instance is woken; a live one keeps serving beside it; one run at a time per instance, refused while it moves or if its box is not yet on the nested layout. The way to run a one-off task against production without a box |
+| `carlos geo <address>` | What the `X-Carlos-Geo-*` headers would say for any address, with the credit the database requires |
+| `carlos sidecars enable\|create\|ls\|wake\|logs\|rm` | A process the platform runs for the app with no socket, hostname or DNS: a bot holding a connection out, a worker. Woken only by its schedule or `wake` |
+| `carlos config destinations` | Name the places one checkout deploys to in `.carlos/config`; `-d <name>` picks one |
+| `carlos domains attach\|detach\|verify\|list\|paths` | `attach --app <a> '*.<parent>'` (quoted) gives every instance a name `<label>.<parent>` with no per-instance claim; `<parent>` must be attached first and a `*` DNS record must point at the deployment. `verify` finds the TXT record a deployment that requires domain proof asks for. `paths <host>` prints the hostname's published path-route map (written through the API โ€” "One hostname, many instances"). Claim customer hostnames (`--wildcard`, `--catchall`); prints the DNS records to create; certs follow automatically. Since 2026-08-27 the platform orders and renews those certs itself, and `list` joins the fleet's readings sweep, so DNS state, certificate expiry and delegation are readable from the terminal โ€” a certificate the platform has given up on now says so instead of sitting pending forever |
+| `carlos store apply\|create\|status\|rotate\|scan` | Object storage. **Once per account, first:** an owner runs `store apply --account <a> --note "<why>"` and a deployment operator approves it โ€” until then `create` has nothing to provision into, and `status` says the account is unapproved or pending. After approval `create` is the whole member workflow (unlimited stores, usage metered); `--environment <e>` gives one config bundle its own bucket and credential; credentials arrive as env; `rotate` is two-phase (`--finish` retires the old key). `scan` is opt-in virus scanning over the bucket's objects (`request`/`status` are yours, `grant`/`revoke`/`sweep`/`enforce` a deployment operator's) โ€” and a scanner cannot read ciphertext, so a client-encrypted store is marked not applicable rather than given a coverage claim |
+| `carlos email enable\|status\|test\|domains\|events\|credentials\|rotate` | Declare sending; provision a verified domain (`domains rm` frees one); SMTP credentials arrive as env; `events` shows what became of each message for 30 days, `events push --path` posts them to the app, signed (`pause`/`resume` are a deployment operator's) |
+| `carlos relay enable\|status\|disable` | The platform's own STUN/TURN relay for an app's WebRTC calls. `enable` delivers `CARLOS_RELAY` (base64 JSON: per-region ICE URLs, a key, a username suffix) and the app mints a TURN credential per call from the key; `status` shows issue, pickup and per-region health. `enable` refuses, with a sentence, on a deployment that runs no relay. Never run coturn or buy a TURN service |
+| `carlos services create\|ls\|rotate\|revoke` | Server-held credentials (`--role deploy\|publish\|instance\|operate\|service\|admin`, `--app` to bind one app, `--rungs` for `deploy`); the secret prints once; `rotate` refuses the old secret on its next call, `revoke` stops the credential outright |
+| `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. An account is *your* tenancy on the platform โ€” never create one per customer of your app (customers are instances: "Provisioning instances from your own app") |
+| `carlos skills` | List the deployment's published agent skills (`/.well-known/agent-skills/index.json`) and fetch one, digest-verified, to stdout |
+| `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.
+
+## Provisioning instances from your own app
+
+A product where every customer gets their own instance creates those
+instances itself, at signup, through the same console API the CLI uses.
+Nothing here needs a box, and nothing here is a workaround:
+
+1. **A person enables the app once** โ€” `carlos instances enable --app
+ <tenant-app>`. This writes the manifest that bounds which domain, pool
+ and regions any instance may be created in, and no credential can do
+ it. Add `--private-storage` and every instance created afterwards
+ gets a config environment named after its host's first label with an
+ object store bound to it, provisioned by the ordinary create call (the
+ account needs an approved `store apply` first, and nothing is torn
+ down when an instance is deleted).
+2. **Mint the credential** โ€” `carlos services create --role instance --app
+ <tenant-app> signup`. Store it with `carlos secrets set` on the app
+ that runs signup, **never on the tenant app itself**: every tenant
+ instance would then hold a token able to delete its siblings.
+3. **Create on signup** โ€” `POST /api/cli/apps/<account>/<tenant-app>/instances`
+ with `Authorization: Bearer <credential>` and a JSON body; `host` is
+ required (one DNS label plus the manifest's domain), `region`,
+ `channel`, `environment` and `message` (the Activity description) are
+ optional. The console writes the
+ record and announces it, and a box mints the route within seconds.
+4. **Wait for the real thing** before sending the person there: poll
+ `https://<host>/api/version` for a 200 (a sub-500 answer is not
+ evidence), behind a zero-JS "getting your space ready" page that
+ refreshes itself.
+
+Give every label a random suffix (`<slug>-<6 random base32 chars>`, the
+label being the host's first DNS segment). That makes a collision with
+somebody else's instance a first-call event, so the one retry below can
+read its 409 without guessing.
+
+The answers to handle, from the apps that run this:
+
+| Status | Meaning | Do |
+|---|---|---|
+| `201` | Created | Wait for `/api/version`, then hand off |
+| `409` | That host already exists (or a sibling app owns the label) | Never treat it as yours: pick a new label. On a *retry of your own timed-out call*, a 409 means the first call worked |
+| `429` | The credential's own rate limit (it is separate from any person's, so a signup burst never starves your promotes) | Show a sentence and a retry; never loop |
+| `502` / timeout | The console could not complete the write | Retry once with the **same** host |
+| `400` | Bad body, or a host that is not one label plus the manifest's domain | A bug on your side; fail loudly |
+| `401` / `404` | Bad credential, or the wrong account or app (every miss is the same 404) | A configuration bug; fail loudly |
+
+Also on the same credential: `GET .../instances` (list), `GET`, `PATCH`
+(reorder or remove addrs, set the probe โ€” adding an upstream address is
+operator-only since v0.25.0) and `DELETE .../instances/<host>`, and
+`POST/PUT .../domains` for customer hostnames. **Deleting an instance
+removes its record, never its data** โ€” the parked database and blobs are
+left in place on purpose, and the call is idempotent.
+
+**The known gap:** the `instance` role is app-wide. One credential can
+create, delete or move *every* instance of the app, not only the ones it
+made. That is why it lives on the signup app alone, and why a signup app
+compromise must be named in your published trade-offs. A narrower,
+create-only credential is a filed platform request โ€” do not build your own
+token broker around it.
+
+## One hostname, many instances
+
+Path routes let one hostname โ€” your product's own domain โ€” serve each
+customer from their own instance, so `example.com/acme` and
+`example.com/globex` are two processes and two databases behind one
+address. The hostname's own route keeps everything else (`/`, sign-in,
+marketing).
+
+- The hostname must be one the app holds: `carlos domains attach --app
+ <hub-app> example.com` first.
+- The map is written **whole** through the API, by a person or by the
+ hub app's `instance`/`publish` credential:
+ `PUT /api/cli/apps/<account>/<hub-app>/domains/example.com/paths` with
+ `{"paths": {"acme": "acme.<tenant-domain>", "globex": "globex.<tenant-domain>"}}`.
+ Keys are one bare path segment (no slash); values are hostnames of
+ existing routes. Publish the full map from your own records every time โ€”
+ a lost delta desyncs silently, a whole republish repairs. `{"paths": {}}`
+ clears it; a body without the `paths` wrapper is refused, so a typo can
+ never wipe the map.
+- **Targets must belong to the same account** (they may be another app of
+ that account โ€” a hub app pointing at tenant-app instances is the
+ intended shape).
+- **Targets must live on the same box as the hostname's own route.** A
+ target homed elsewhere is silently ignored and its prefix falls through
+ to the hostname's route. That caps a path-routed product at one box
+ today; cross-box path targets are a filed platform request.
+- A write goes live in about five seconds. `carlos domains paths --app
+ <hub-app> example.com` prints what was published; `carlos routes`
+ shows what the box actually applied.
+- The upstream receives the original path (`/acme/...`) unchanged, and the
+ target route is what is billed, versioned and woken.
+- **One hostname is one browser origin.** Cookies, storage and script
+ are shared across every prefix, so every tenant's pages can act on the
+ hub's session. That is fine while every tenant runs your app's code; if
+ tenants serve HTML their own customers wrote, give each tenant its own
+ hostname instead.
+
+## 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.
+
+## Standing a canary beside production
+
+Review happens on a deployed canary, never on localhost, and one command
+does it:
+
+```
+carlos canary --app jam ./jam
+```
+
+The name defaults to the slugified git branch, so branch `new-queue`
+gives channel `canary/new-queue` and host
+`new-queue-jam.<sqid>.oncarlos.com`. An explicit `--name` is validated
+rather than quietly repaired, because the same string has to serve as
+both a channel segment and part of a hostname label: lowercase letters,
+digits, dashes, and at most 63 characters with the app name. `carlos canary ls` shows each canary's host, what it serves,
+which config bundle it is bound to and what is left of its seven-day
+lease; `rm` deletes the instance record and the host stops being served.
+The channel lapses on its own.
+
+**If the app carries config, pass `--environment`.** This is the trap,
+and it is worth stating in full because the failure looks like an app
+bug. Config in CARLOS is per-app, so a canary with no bundle of its own
+serves the app's *default* bundle โ€” where every origin-shaped value names
+the app's **main** host. Anything keyed to that single scalar refuses
+requests on the canary hostname: the `Origin` header the app compares
+against, magic-link and callback URLs, a WebAuthn RP ID, the cookie
+`Secure` flag. The result is a canary that serves every GET perfectly and
+fails every form submission. Write the bundle first, then bind it:
+
+```
+carlos env --app jam --environment jam-canary JAM_ORIGIN=https://new-queue-jam.<sqid>.oncarlos.com
+carlos canary --app jam --environment jam-canary ./jam
+```
+
+The same applies to any second instance standing beside a live one, not
+just a canary โ€” `carlos instances create --environment`, or
+`carlos instances set-environment` on a route that already exists,
+followed by a restart to pick the values up.
+
+## Watching an app: errors, availability and analytics
+
+All three read from the terminal or the app's dashboard tabs. **Errors
+and availability collect from the day the app ships** โ€” nothing to turn
+on. **Analytics is off
+until you switch it on** (a deliberate ruling, 2026-09-07: counting
+visitors is the app's call, not the platform's).
+
+```
+carlos errors --app hello --since 24h
+carlos availability --app hello --since 24h
+carlos analytics --app hello --set-count edge
+carlos analytics --app hello --range 7d
+```
+
+`carlos errors` needs no wiring at all: it groups `error`-tagged log
+lines (or `warn` too, with `--set-level warn`) together with the 500s
+the app itself answered, one row per distinct error with a count and a
+location. `--fp <fingerprint>` expands one group to its occurrences.
+
+`carlos availability` (since v0.22.0) holds what used to sit in errors:
+every 502, 503 and 504 the edge saw, meaning the instance could not
+answer, grouped by cause (refused, closed mid-request, timed out, no box
+connected, or the app's own 503), with failed wakes and exits beside
+them. One outage is one row however many pages it hit; `--fp` expands a
+cause to each request and path. A visitor who gives up after five
+seconds or more without response headers is filed as an outage, so an
+endpoint that holds its headers by design, like a long poll, shows a
+standing row there, and that row is expected.
+
+`carlos analytics` counts at the edge as it serves the app: page views,
+daily visitors, bytes served, top pages, top referers, named events โ€”
+**no JavaScript, no cookie, no stored IP**, which is why an app on CARLOS
+has no business reaching for a third-party analytics script. Visitors are
+a sketch, so a multi-day total is daily visitors summed rather than a
+deduplicated unique count. The switch is per-app or account-wide.
+Where the deployment publishes a GeoIP database, page views are counted
+by country too.
+
+Neither replaces `carlos logs`, still the merged app + platform + edge
+timeline (`-f`, `--grep`, `--since`, `--stream app`, `--host`) with no
+box access.
+
+## 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) { // amadan.net/rastrillo/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.
+- **A schedule tighter than the idle window keeps the instance resident,
+ and residency is billed.** Since 2026-08-31 a tick no longer stamps
+ activity, but the sweeper deliberately will not put a host to sleep
+ when its next tick is due sooner than the idle window (15 minutes by
+ default) โ€” staying awake is genuinely cheaper than a wake/sleep cycle
+ per tick, each of which is a full SQLite checkpoint-and-restore. So a
+ 5-minute schedule against a 15-minute window never sleeps and accrues
+ instance-hours around the clock; an hourly one sleeps about three
+ quarters of each hour. `carlos schedule ls` and `set` now say so in one
+ line rather than leaving it to be found on a bill. The platform warns
+ and never refuses: a tight schedule is a legitimate thing to want.
+- **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.
+
+## Two builds at once
+
+Since v0.26.0, where the deployment has switched it on (Carloku did on
+2026-10-14; the release default is off, and then a build is taken by
+restart and `deploy` watches the URL instead of each box's rollout), a
+new build reaching an awake instance starts beside the old one and takes over once it passes
+its health check, so no request waits (`--swap overlap`, the default).
+For a few seconds both builds run against the same database and the same
+scheduled work, and the new build's migrations run while the old one
+still serves. That makes two rules part of every release:
+
+- **Migrations stay additive across two consecutive releases.** Add a
+ column, table or index in one release; drop the old one in a later
+ release, once no running build reads it. A rename is an add now and a
+ drop later.
+- **Schedule names stay the same across the two builds**
+ (`carlos schedule`).
+
+A release that cannot follow them ships with `--swap hold` on
+`carlos deploy` or `carlos ship`: each instance stops before its new
+build starts, and requests wait a few seconds for it.
+`carlos apps set <app> --swap hold` makes that the app's rule, and
+`--swap-concurrency` is how many instances on one box swap at once,
+after the first has stayed up for ten seconds. If a build fails on a box,
+that box stops rolling it out, its other instances stay on the build they
+were serving, and `carlos deploy` names the failure and exits non-zero;
+`carlos instances list` shows it under REASON. Fix the cause, then
+`carlos promote --retry <version> <channel>` (every box) or
+`carlos instances retry <host>` (one instance).
+
+## Visitor location headers
+
+On a deployment that publishes a GeoIP database (since v0.25.1),
+every request the edge passes to an app carries the visitor's rough
+location: `X-Carlos-Geo-Country` (ISO 3166-1, `IE`),
+`X-Carlos-Geo-Subdivision` (ISO 3166-2, only some databases),
+`X-Carlos-Geo-Lat` / `-Lon` (two decimals: an area, not a position) and
+`X-Carlos-Geo-Source` (which database answered). Any of them can be
+missing: treat that as unknown, never as an error. The edge strips any a
+visitor sends, so the values are always the platform's. A cached page
+that differs by country sends `Vary: X-Carlos-Geo-Country`; never vary on
+the coordinates.
+
+**An app that shows a visitor anything worked out from these headers
+must credit the database `X-Carlos-Geo-Source` names** โ€” `DBIP-โ€ฆ`:
+"IP Geolocation by DB-IP", linked to https://db-ip.com; `GeoLite2-โ€ฆ`:
+"This product includes GeoLite2 data created by MaxMind, available from
+https://www.maxmind.com". For an address that is not the current
+request (a log line, a webhook), `carlos geo <address> --app <a>`, or
+from an app `GET /api/cli/apps/<account>/<app>/geo?ip=<address>`, at
+most 60 a minute. That call needs an `instance`-role token, which is
+app-wide (it can also create, repoint and delete every sibling
+instance), so make it only from a central app that already holds one;
+never deliver that token to a tenant app's instances just to look up an
+address. Tenant instances use the request headers.
+
+## 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 follow each
+ box's rollout (or, where a box does not report one, watch the URL)
+ until the shipped build is serving. "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. An awake
+ instance starts the new build within seconds of its box adopting it
+ (beside the old one where two builds at once is on), an asleep one
+ takes it on its next request, and unit-stamped routes are restarted.
+ 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
+ proof is the build the process itself reports: set
+ `carlos apps build-probe`, or read `X-Carlos-Version` on the canonical
+ host knowing the edge stamps it before the process has cycled; stale
+ DNS and stale local binaries have both produced false "verified"
+ reports.
+- **Review on a canary, not on localhost.** `carlos canary` cannot
+ disturb production: its channel sits outside every pipeline and its
+ hostname is the branch's own. Bring a config bundle with it if the app
+ has one.
+- **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 amadan.net/rastrillo/rastrillo.BuildVersion=<sha>"`
+ โ€” or every release's `/api/version` reports `dev`.
+
+## Switched off, or not released
+
+Released, switched off on Carloku โ€” the CLI has the flag, the console
+declines or ignores it, and nothing should be built to stand in for
+them: app residency (`--region` on `apps create`, `deploy` and `canary`
+since v0.23.0; the console starts the app in its home region and says
+so), instance moves (`carlos move`, v0.26.0; it sends nothing), and
+temporary static sites (`deploy --temporary` / `--ttl`, `apps ttl|keep`,
+v0.25.1). Names starting `tmp-` are reserved whether or not temporary
+sites are on.
+
+Released in v0.28.0 but refused until an operator turns them on:
+seeded instances (`instances create --from <host>` starts a new
+instance from a copy of another's database; `instances import` brings
+in one the platform never ran). Not built: an
+instance-bound token in place of the app-wide `instance` role, a
+create-only provisioning role, and cross-box path targets. Check
+`carlos <verb> -h` against the console and wait.
+
+## 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).
+
+The deployment's own binary is a channel like any app's:
+`carlos system update --channel canary|stable` promotes a release, and
+every box on that channel fetches it, verifies it against the signed
+pointer, installs it, restarts its edge, and rolls itself back if the new
+one does not come up and stay up. `carlos system status` shows each box's
+running build and last apply outcome; `carlos system rollback` undoes it;
+`--spread 10m` staggers a stable roll. The console is an app โ€” update it
+with `carlos deploy --app console`. Owner of the ops account only.
+
+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/vendor/carlos-skills/building-carlos-apps/references/process.md b/vendor/carlos-skills/building-carlos-apps/references/process.md
new file mode 100644
index 0000000..59eeb20
--- /dev/null
+++ b/vendor/carlos-skills/building-carlos-apps/references/process.md
@@ -0,0 +1,192 @@
+# ๐Ÿค– 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`
+ on GitHub; on amadan, where the branch is the pull request,
+ `amadan branch merge` makes a merge commit and `-squash` lands one
+ commit; never squash by hand there, or the branch shows as deleted).
+ 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 canary` deploys to
+ `canary/<slug>` (a per-session dead-end rung, zero bake) and the canary
+ host serves it at
+ `<slug>-<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** (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/vendor/carlos-skills/building-carlos-apps/references/rastrillo.md b/vendor/carlos-skills/building-carlos-apps/references/rastrillo.md
new file mode 100644
index 0000000..0a53932
--- /dev/null
+++ b/vendor/carlos-skills/building-carlos-apps/references/rastrillo.md
@@ -0,0 +1,218 @@
+# Building an app with rastrillo
+
+The CARLOS web framework. **Module and canonical repo:
+`amadan.net/rastrillo/rastrillo`** (since v0.25.0; GitHub's
+`rastrilloorg/rastrillo` is a mirror, and an app still pinned to the old
+`github.com/carlosframework/rastrillo` path builds but should move).
+Status as of 2026-10-15: **v0.28.0** (released 2026-10-06), the
+known-libraries middle layer: GORM models or plain SQL, 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
+migration, scoping and mass-assignment rules, the identity plugins, the
+UI vocabulary and the platform contract. Find it at
+`$(go env GOMODCACHE)/amadan.net/rastrillo/rastrillo@<version>/SKILL.md`
+once the module is downloaded; any page it cites is
+`curl -s https://rastrillo.org/docs/<page>.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`), one writer and
+ several readers, WAL pragmas in the proven order. Since v0.28.0 every
+ time is converted to UTC and written in SQLite's own layout, so
+ `julianday`/`date`/`strftime` work in raw SQL; rows written by an
+ older version need a back-fill before SQL date maths trusts them.
+- `migrate` โ€” ledgered migrations applied once at boot, never re-run,
+ never reversed: `rastrillo migration generate` after a model change
+ (read the SQL before committing), `migration check` in CI, `migration
+ new` for a hand-written rename. Destructive changes need
+ `--allow-destructive`; the family's own rule stays additive-only, and
+ on CARLOS two builds share the database during a swap, so a drop waits
+ a release (platform.md, "Two builds at once"). Since v0.28.0 a Go
+ migration's `Fn` takes the pinned `migrate.Tx`, not a `*gorm.DB`
+ (wrap old bodies in `migrate/gormfn.Fn`).
+- `sessions` โ€” SQLite-backed rows (revocation is real), `__Host-`
+ cookies on https, `Require` / `RequireFresh` (step-up).
+- Identity plugins: `auth` โ€” 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 โ€” and `password` (email and
+ password, rate-limited). Either one's whole contract is calling
+ `sessions.SignIn`. **Since v0.28.0 both sit behind `pow` by default:**
+ `auth.New` and `password.New` return `ErrProofUnset` until you set
+ `Proof` (a `*pow.Guard`) or `ProofOff`. Mount `auth.Verify` on **POST
+ as well as GET** โ€” GET now draws a Sign in button and spends nothing,
+ so a mail scanner cannot burn the link; GET alone 405s the button.
+ `auth.Config.SigninScreen` turns on the shipped sign-in screen.
+ `secondfactor` is the gate between a first factor and a second;
+ `passkey` and `totp` (authenticator apps) plug into it, and recovery
+ codes live there (`passkey.Config.ConfirmPath` is gone; merge
+ `secondfactor.Schema` after `passkey.Schema`).
+- `csrf` (origin-checking, not tokens), `flash`, `form`, `view`,
+ `scope` (`scope.Owned` โ€” owner scoping with the 404-not-403 contract),
+ `jobs` (request-started background work a person watches).
+- `blobs` (bytes in the platform's object store, never on disk),
+ `crypto` (+ JS twin), `keyring` (the E2EE seed lifecycle), `webauthn`,
+ `eventlog`, `mail` (including RFC 8058 one-click list mail), `ui`.
+- `carlos` โ€” your side of the platform's tick (`carlos.Tick`,
+ `ScheduleAt`); platform.md carries the contract.
+- `assertion` (v0.27.0) โ€” signs and verifies a short-lived identity
+ handed from one exact HTTPS origin to another (P-256, โ‰ค120 s,
+ browser-bound nonce). It proves who, never what they may reach, and it
+ leaves replay and session creation to the receiver.
+- `vault` โ€” the client for Pegamento's vault (named sealed blobs, a seed
+ wrapped per sign-in method), for server-blind apps.
+- `pow` โ€” a proof-of-work front door for public forms that have no
+ session yet (signup, contact), and since v0.28.0 for sign-in and
+ sign-up. The v0.28.0 API is new: `Issue` became `Form(now, scope)`,
+ and `Check` split into `Admit` before the transaction and
+ `adm.Commit(ctx, tx)` inside it (`Check` remains for a redirect-only
+ handler).
+- `harness` โ€” a `-tags browser` Chromium test rig with a virtual
+ authenticator, for ceremonies a Go-only test cannot reach.
+- New in v0.28.0: `perf` (`Server-Timing` and first-byte budgets as
+ middleware), `budget`, `clientip` (with `auth.Config.TrustedProxyHops`,
+ 1 by default on CARLOS so each visitor behind the edge gets their own
+ sign-in limit), `background` (tracked goroutines and timers that
+ `Serve` stops before closing the database), `table` and `xlsx`
+ (formula-safe CSV/XLSX exports), `dbtest` (a migrated database per
+ test), `nodetest`, `lastsignin`, `secondfactor` and `totp`. Static
+ assets carry strong ETags.
+- The platform layer: `Resolve`/`Serve` speak CARLOS activation (argv,
+ `LISTEN_FDS`, `$STATE_DIRECTORY`, `/healthz`, `/api/version`, SIGTERM
+ drain) and set baseline security headers on every response,
+ `Strict-Transport-Security` included. **The baseline CSP allows no
+ inline style** (v0.27.0): a `style="โ€ฆ"` attribute or a `<style>` block
+ is dropped by the browser without an error.
+
+Separate modules, each with its own `SKILL.md`, none imported by the
+framework: **`amadan.net/rastrillo/idear`** (roles โ€” exactly one Owner,
+Admins, Members โ€” and invitations inside one instance), and the client
+kits `amadan.net/rastrillo/pwa`, `amadan.net/rastrillo/native` and
+`amadan.net/rastrillo/aviso` (Web Push). Use each module path verbatim;
+there is no `github.com/carlosframework/idear`.
+
+## The ten-minute path
+
+```sh
+go install amadan.net/rastrillo/rastrillo/cmd/rastrillo@latest
+rastrillo new myapp && cd myapp && go mod tidy && go test ./...
+make ci # the gate
+```
+
+The scaffold is SKILL.md's shape โ€” five files plus `migrations.go` beside
+`models.go` โ€” with `go.mod`, templates, static assets, a test harness
+including a browser drive, and a `Makefile` whose **`ci` target is the
+gate** (`vet`, a gofmt check, staticcheck, govulncheck, gitleaks, the
+size and timing budgets, the tests, `perf` and `migration check`, on a
+Go release pinned by `GOTOOLCHAIN`; add `generate --check` once the app
+declares manifest resources). Apps scaffolded before v0.28.0 keep their
+old gate until they add these by hand (the CHANGELOG has the lines). It compiles, passes and serves before you
+write a line. `--theme=day|plain|signal` and
+`--shell=column|topbar|sidebar|console|stage` pick the look; both land as
+app-owned files. `CGO_ENABLED=0` throughout โ€” a cgo SQLite driver
+sneaking in is a bug.
+
+## 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; `idear` gives them
+ roles.
+- 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 one-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 `auth` plugin, read the viewer with `auth.From(r)` or
+ `sessions.Current(r)`, never `sessions.UserID`: it returns
+ `(0, false)` for an email Subject, and dropping that `ok` scopes every
+ query to uid 0.
+- One `migrate.Apply(ctx, d, BootSchema)` at boot. Subsystem schemas
+ (sessions, and any other package that brings tables) go in `BootSchema`, never `Schema`, or `migration
+ check` proposes dropping their tables. Never edit a shipped migration.
+- Background work a person is watching: `jobs`. Work at a time nobody is
+ waiting for: the platform's tick (`carlos schedule set`, then
+ `carlos.Tick` in the handler) โ€” an idle instance runs no timer.
+
+## Design system and app CSS
+
+Use Rastrillo's design system as the default foundation for app screens.
+Before styling, read `docs/site/templates.md`, `docs/site/forms.md` and
+`docs/site/reference/ui.md` in the Rastrillo version the app uses; the
+full gallery is rastrillo.org/design-system.
+
+- **The vocabulary is attributes, not classes** (v0.22.0+):
+ `<div rst-box>`, `<a rst-btn="primary">`, `<form rst-form>`,
+ `<div rst-callout-body>`. `class` carries only a few
+ utilities (`rst-sr-only`, `rst-mono`, `rst-grow`, โ€ฆ).
+ `rastrillo markup --fix` converts markup written the old
+ `class="rst-โ€ฆ"` way.
+- Load the shared `tokens.css` before the app stylesheet, and keep that
+ base intact, with branding and layout in a separate app stylesheet.
+- Compose screens from `ui` partials. A labelled control is never
+ hand-rolled: `field-text`, `field-textarea`, `field-select` inside
+ `<form rst-form>`, closed by `form-foot`. A submit is
+ `rst-btn="primary lg"` (form-foot writes it).
+- **No `style` attributes and no `<style>` blocks** โ€” the CSP drops
+ them silently. For a grid card, give `rst-card` a class and set
+ `--rst-cols` in your stylesheet.
+- Keep the CSS above Rastrillo thin: app-specific layouts, scoped styles
+ for missing components, and a few deliberate `--rst-*` token
+ overrides. No second palette, spacing scale, or button/input system,
+ no broad element resets.
+- Preserve the shared focus, disabled, error and responsive behaviour;
+ check light and dark themes and keyboard navigation.
+- The vendored `static/` files are not refreshed by a module upgrade.
+ `rastrillo doctor [--fix]` compares them with the CLI's copies; the
+ scaffolded pin test is the standing gate.
+
+A repeated workaround for a shared component is a candidate for an
+upstream Rastrillo change. An explicit design brief or an existing app's
+design system can justify another foundation; name that choice rather
+than silently replacing the default.
+
+## Manifests are the declarative path
+
+A `manifest/*.toml` resource generates CRUD screens โ€” field kinds text,
+textarea and money, one flat record per resource, no relations;
+`scope = "user"` owner-filters every generated query by the session
+subject. It is an equal alternative to hand-written handlers: mix the
+two per resource and move a resource between them freely. `rastrillo
+generate` writes `gen/` (commit it, never hand-edit); `generate --check`
+belongs in `make ci`. Relations or custom flows: hand-write.
+
+## Copy from, in order
+
+1. `examples/notes` in the repository (not in the published module โ€” Go
+ excludes nested modules from a zip): accounts, sessions, CSRF, flash,
+ owner-scoped resources, a background job, and a two-user isolation
+ test suite.
+2. `examples/tickets` โ€” the declarative (manifest) path.
+
+## Designed, not released
+
+Don't build on these yet: Keymail upgrade becoming an opt-in adapter
+rather than `auth`'s default; SAML and OIDC identity plugins; a sealed
+magic-link store; the user-models work (an `objrec` record store, which
+is on a branch, a stateless auth store, signed-cookie sessions, an
+assertion HTTP adapter, provisioning, a base path,
+`rastrillo new --model`). On main after v0.28.0: `carlos.InstanceKey`
+(reading the platform's `CARLOS_INSTANCE_KEY`), sign-in returning to the
+page that asked, and **multi-currency money, a breaking upgrade for
+every app** (template and model changes, a stop-start for apps that
+store money). Wait for the release and its upgrade steps; never build
+against main.
+
+Deploying: stamp
+`-ldflags "-X amadan.net/rastrillo/rastrillo.BuildVersion=<sha>"` or
+`/api/version` reports `dev`.
diff --git a/vendor/carlos-skills/building-carlos-apps/references/tenancy.md b/vendor/carlos-skills/building-carlos-apps/references/tenancy.md
new file mode 100644
index 0000000..7b544cc
--- /dev/null
+++ b/vendor/carlos-skills/building-carlos-apps/references/tenancy.md
@@ -0,0 +1,360 @@
+# ๐Ÿค– Tenancy: what one instance is, and what that buys
+
+The first decision for any CARLOS app, before trust model, shape, hosting
+or identity (decisions.md). Every other benefit on carlosframework.com
+follows from it: isolation, hibernation, residency, erasure, taking the
+data home. Settle it on day one and write it down as a **tenancy
+declaration** (ยง6). Changing it later is a data migration across every
+instance, not an edit.
+
+Status as of 2026-10-18. Carloku serves platform v0.28.0; rastrillo is
+v0.28.0. Where a piece below is designed but not shipped, it says so.
+
+## 1. The boundary: what is one instance?
+
+**The instance boundary is the app's choice** (the family doctrine). No
+unit is the default. The gallery at carlosframework.com/apps runs all of
+these, and all of them are correct for their app:
+
+| One instance isโ€ฆ | Person : instance | Apps that do it |
+|---|---|---|
+| a person | 1 : 1 | Kass (a training log; a coach is a *member* of the client's instance), Slopbox (one account), Dineraya (a buyer) |
+| a conversation or space | 1 : N | Eleven (one chat) |
+| a mailbox | 1 : 1 | Keymail |
+| a team | N : 1 | Oficina (one per team, per app: docs, sheets), Correomona, an internal finance ledger |
+| a customer organisation | N : 1 | Tito (one per organiser account, plus a sandbox instance each), Dineraya (a merchant), Jelly (designed: one per signup) |
+| a community | N : N | Woodstar (a relay: founder plus invited members) |
+| a repository | N : N | amadan (one per repo; the hub holds only names and membership) |
+| a connection | 1 : 1 | a payments app with one instance per bank connection |
+| the whole app | N : 1 | Movement (one sign-up list); a rastrillo app out of the box |
+| nothing: no user data | none | static sites (carlosframework.com, carloku.com) |
+
+Most products run **more than one pattern**: a central app plus per-unit
+instances (Tito, amadan, Oficina, Slopbox, Eleven), or one
+binary in several roles (Dineraya: buyer, merchant, and one shared
+dispatcher).
+
+**Choosing the unit.** Ask, in order:
+
+1. **Whose data is it?** The unit is the smallest group that owns data
+ together and would be harmed if it leaked to the next group.
+2. **What should fail together?** A panic or a runaway query takes down
+ one instance. Choose the unit whose outage you can explain to one
+ customer.
+3. **Who leaves together?** A unit's data is its own database and its
+ own objects, so exporting it is copying them and erasing it is a
+ bounded list (the platform keeps a deleted instance's data until you
+ remove it). Pick the unit a customer would ask to
+ export, delete or move to their own boxes.
+4. **What crosses units?** Sharing is **membership**, never copying: a
+ person joins someone else's instance (a coach in a client's log, a
+ member in a team). If a feature needs to *query* across units
+ (reporting, search across all customers), the unit is too small or
+ that feature belongs in its own app. A central app answers "what
+ might I have access to", never "may I have this". A view across
+ tenants (a vendor's dashboard, a person's search over their own
+ spaces) has its own pattern: ยง2 "Seeing across tenants".
+5. **Does it idle?** Instances that sit idle hibernate and cost nothing.
+ Small units that are used in bursts get the most from this.
+
+**The anti-pattern: tenancy by `WHERE` clause.** One instance, a
+`tenant_id` column, every customer in one file. It gives up isolation,
+per-unit erasure, per-unit residency and per-unit hibernation at once,
+and one missed `WHERE` exposes everyone. On CARLOS, isolation is a
+process and a file. Use one shared instance only when the whole app
+really is one unit (one team, one list).
+
+## 2. How people reach their instance
+
+Four recommended models, from the user-models design (approved
+2026-09-28). They are recommendations, not rules: if none fits, choose
+your own and record why.
+
+| | Model | You sign in toโ€ฆ | Recommended when | Runs it today |
+|---|---|---|---|---|
+| A | **One instance, in-app accounts** | the app itself | one team, one household, one list, a personal tool | the internal finance ledger, Movement, the rastrillo default |
+| B | **Simple home** | *your* instance | each person or team owns a space that stands alone | Oficina, Kass (in-process), the bank-connection app |
+| C | **Hub** | a whole product | people belong to several spaces and move between them all day | Tito (subdomains and a proxy), amadan (path routes) |
+| D | **Blind home** | your instances, server-blind | end-to-end encrypted apps where the centre must hold nothing readable | Eleven's home, Slopbox's home; `rastrillo/vault` is the client |
+
+- **A** is the least machinery: rastrillo `auth` or `password`, owner
+ scoping, and `idear` for roles and invitations. Its cost is that one
+ instance's blast radius is everyone in it, and it never grows into a
+ product with many independent customers.
+- **B** is a small central app where you sign in, see your instances,
+ add one, and open one. Each instance has its own hostname and its own
+ sessions, and still accepts ordinary sign-in on its own origin, so it
+ works if the home is down. The home hands you in with a signed,
+ single-use assertion (rastrillo `assertion`, issuer the home, audience
+ the instance), posted in a form, never in a URL. Smallest leak profile
+ of the recommended models: the home knows who signed up and which
+ instances they own, nothing inside any of them. Cost: no shared chrome;
+ moving between spaces goes through the home.
+- **C** puts the product on one domain and each tenant under a path
+ (`example.com/acme`) via the platform's path routes. One origin, one
+ session, one switcher; every tenant still its own process and file.
+ Costs: one origin is one browser security boundary, so use C only when
+ every tenant runs the app's own code (a product whose tenants publish
+ raw HTML their customers wrote needs B's per-tenant hostnames). The
+ hub's session-signing key is the product's crown jewel. **Today a path
+ target must live on the same box as the hostname**, so a path-routed
+ product is one box big.
+ How it fits together: the tenant receives `/acme/...` unchanged, so the
+ tenant app serves everything (routes, assets, redirects, form actions)
+ under its base path, and its own direct hostname redirects to the
+ product path. Because hub and tenants share one origin, there is one
+ session: the hub mints a short-lived signed cookie (15 minutes, with a
+ per-user epoch so "sign out everywhere" works), and tenants hold the
+ hub's public key (`carlos env set`) and verify it on every request,
+ never minting their own. Ordinary per-tenant rastrillo sessions do not
+ work under model C: every tenant would set the same cookie name on the
+ same origin and sign the others out. The hub delivers roster changes to the
+ instance first, with a signed request, then records its directory
+ copy.
+- **D** is B's shape for server-blind apps: the home holds credential
+ public keys and one sealed blob per person and can read neither.
+
+**Central apps are stateless** (ruled 2026-09-27, a foundational CARLOS
+principle). A home, hub, directory or sign-in service keeps no database:
+its records are objects in its own store (`carlos store create`), created
+with `If-None-Match: *` and updated with `If-Match`, at keys it can
+compute (`users/<uid>.json`, `tenants/<slug>.json`). Nothing to
+replicate, restore or migrate, and it moves between boxes with nothing
+to copy. Anything relational (reporting, search) belongs in an instance.
+
+**Safety invariants, whichever model:**
+
+1. The provisioning credential (`carlos services create --role instance
+ --app <tenant-app>`) lives on the central app, never on the tenant app.
+ It is app-wide today: it can also delete any sibling instance. Publish
+ that as a leak until the platform ships a create-only role. The same
+ goes for any `instance`-role token (`CARLOS_INSTANCE_TOKEN`): never put
+ one in the tenant app's secrets, whatever the convenience.
+2. An instance decides access from its own roster. A signature proves
+ who, never what.
+3. Handoff tokens never ride in URLs, and the receiver enforces single
+ use.
+4. Model C only where every tenant runs the app's own code.
+5. Every model publishes its leak profile (ยง3).
+
+**What is real today, and what is not** (2026-10-15):
+
+| Piece | State |
+|---|---|
+| Instances API, `instance` service role, path routes, `assertion`, `vault`, `idear`, stores | Shipped. Use them. platform.md "Provisioning instances from your own app" |
+| Create-only, quota-bounded provisioning role | Not built (platform #185). Use the `instance` role and name the leak |
+| Path targets on another box | Not built (platform #186). Model C is one box big |
+| rastrillo `objrec`, stateless auth store, signed-cookie sessions, `provision`, base-path serving, `rastrillo new --model home\|hub` | Not released (`objrec` is on a branch). Hand-build on the patterns above, and don't claim the framework does it |
+
+### Seeing across tenants
+
+A vendor wants totals across every customer; a person wants to search
+every space they belong to. Tito runs both with one rule: **the centre
+reports only what it owns or witnesses. It never opens a tenant's
+database.** Four ways data legitimately crosses the boundary, in order of
+preference:
+
+1. **Witnessed facts.** Make the number a fact the vendor owns (a fee, a
+ charge, usage) and record it where the vendor already sits on the
+ path: the proxy, payment egress, mail egress, a payment provider's
+ webhook. Tito's staff console gets collections, request timings and
+ capacity this way, from facts its router, payment relay and the
+ payment provider saw for themselves. Fees *owed* are partly computed
+ from facts instances report, which is step 2.
+2. **Pushed facts through an outbox.** Where only the instance knows, it
+ writes a fixed-shape record (a fee accrual, a ticket count) to an
+ outbox table in the same transaction as the work, and a sender
+ delivers it to the central side, retrying until accepted. The
+ instance is usually asleep, so send at the end of the request that did
+ the work, and let anything undelivered go on the next wake or a daily
+ `carlos schedule` tick (a tighter schedule keeps the instance awake,
+ and that is billed). Retries mean duplicates, so each record carries a
+ stable id; the receiver dedupes on it in the same transaction as the
+ ledger write, acknowledges only after commit, and refuses a different
+ payload under an id it already holds (Tito does all four for fees). The record's
+ shape is declared residue, published like any other. The receiver
+ files each record under the instance that sent it, so **an instance
+ can only speak for itself**. Short self-descriptions (name, region,
+ counts) can also ride on responses the centre already proxies.
+3. **Browser fan-out for a person's own view.** A search or switcher
+ across someone's spaces asks each instance they are a member of,
+ from the browser. Every instance answers as it would any other request
+ from that person: membership, then their role's capabilities and any
+ narrower scope (Tito filters by the events and series a member may
+ see), so a result list never shows what the person could not open. No
+ central copy; nothing widens the set beyond real memberships. Tito
+ does this on one origin (model C), where the one session already
+ reaches every tenant. Across per-tenant hostnames (model B) each
+ request needs that origin's own session, so there it is usually a
+ link per space instead. Every request can wake a sleeping instance,
+ so Tito answers typing from a cache the browser built from each
+ instance's own answers (refreshed when that account's digest changes),
+ and asks the instances live only when the person asks for more
+ results, at most four at a time.
+4. **One tenant at a time, with consent.** Support access is granted from
+ inside the tenant (in Tito, by a member, never above their own role,
+ and capped at admin), time-limited, and logged before the first read,
+ failing closed if the log write fails. Never aggregation.
+
+**Where the vendor's records live.** Not in the router, hub or home. A
+**back office app** with its own instance and database holds the
+vendor's ledger, partners and statements, receives the outbox, takes
+signed identity like any tenant instance, and asks the central app for
+anything else through a narrow API (list accounts, create one). Tito's
+console holds these in its router today; its proposed split moves them
+into a back office app of this shape.
+
+**Not allowed:** a central app or reporting job opening tenant databases
+(`carlos db pull` across every instance is a backup of everyone, not a
+report); copying tenant content (buyers, messages, documents) into a
+central store; a `tenant_id` warehouse.
+
+**The sender problem (2026-10-16).** Step 2 is only as good as the
+receiver's knowledge of which instance sent a record. It must derive
+that from a credential bound to the sending instance and verified by the
+receiver, never from a name the sender supplies, and never from the
+app-wide `instance` token, which every holder can present for any
+sibling. Tito today trusts box-local sockets plus a header the instance
+sets, so a compromised instance could report as a sibling. The family has
+no ready-made per-instance sender credential on Carloku: platform tokens
+are app-wide (#155, not built), and even scoped platform tokens would
+not by themselves authenticate an instance to *your* receiver. Until a
+pattern exists, cross-check pushed facts against what you witnessed
+where you can (a fee against the provider's webhook), keep pushed
+records to facts a lying instance gains little from, and name the gap as
+a leak.
+
+## 3. What the host can see: the trust class
+
+Tenancy says where the walls are; the trust class says what a host can
+read inside them. Pick a class per **workload**, not per product: an
+end-to-end encrypted app with an automation sidecar is two answers (Eleven
+and Kass: instance A2, agent sidecar C). Classes order S โ‰ค A < B < C; the
+A variants are flavours, not grades.
+
+| Class | The host holds | Examples |
+|---|---|---|
+| **S** stateless | no user data at all | static sites; a central app holding only public keys can be S plus declared residue (Dineraya's dispatcher) |
+| **A1** host-blind | ciphertext; only transport shape leaks (who connects when, sizes, timing) | Slopbox (padded blobs, encrypted names) |
+| **A2** host-blind, declared residue | ciphertext plus a published list of plaintext metadata | Keymail (routing headers), Kass (log timestamps), Eleven (thread membership), Dineraya buyer |
+| **A/pub** public by design | A, plus surfaces that are public on purpose, listed one by one | Woodstar (public posts; DMs and blobs sealed) |
+| **B** sealed single-tenant | sealed at rest (parked DB, replica, blobs); the live instance holds a key while awake because unattended work needs plaintext | an internal finance ledger, one instance per team (the defining example); Dineraya merchant |
+| **C** host-sighted | plaintext at rest, chosen and written down: support visibility, SQL over personal data, payments | Tito (the defining example) |
+
+Several apps have not published a class letter yet (amadan's is
+unresolved; Oficina, Movement and Correomona describe their
+posture in words, without a letter). Don't assign one on their behalf.
+
+**The test is the CARLOS one: publish the database and see what leaks.**
+A leaks its declared residue and nothing more; B's cold copy leaks
+nothing; C leaks the data, and said so up front. Enforce it in CI: push a
+marker through the app, then grep the raw SQLite bytes and bucket objects
+for it. An undeclared plaintext surface is a class violation whatever the
+intent. decisions.md ยง1 has each app's recorded reasoning.
+
+**Placement follows the class (designed, not yet enforced).** Hosts are
+tiered by who controls the box: 0 the operator, 1 a named counterparty
+under a data-processing agreement, 2 a stranger. S and A run anywhere, B
+never on tier 2, C only on tier 0 or where the agreement names the data.
+As of 2026-10-15 the platform does not record or enforce trust classes,
+so declare the class in your README and AGENTS.md now; don't tell users
+the platform enforces it.
+
+**Two more axes, independent of the class:** a **licence class** (L1
+libre, L2 source-available commercial, L3 open with a hosted offering, L4
+closed hosted-only, L5 closed hosted and on-prem: Tito, Jelly) and three
+**remix flags** (forkable, agent-driveable through typed tools,
+agent-rebuildable from a published skill). An L5 app can be class A; an
+L1 app can be class C.
+
+## 4. What the choice buys
+
+The benefits on carlosframework.com, each with the mechanism behind it
+and what it depends on. State a benefit only with its mechanism, and only
+the ones your tenancy actually earns.
+
+| Benefit | Mechanism | Depends on | State on Carloku, 2026-10-15 |
+|---|---|---|---|
+| **Isolated data per tenant** | each unit is its own process, SQLite file and socket; instances get CPU and IO limits and no loopback or private-network access | a boundary smaller than "the whole app" | live |
+| **Blast radius of one** | a panic or runaway query kills one process; the next request restarts it | same | live |
+| **A security claim you can falsify** | publish the database and see what leaks; the residue is declared | a declared class plus the CI grep test | yours to write |
+| **Multiple trust classes** | one product, a class per workload (instance, sidecar, central app) | splitting workloads that need plaintext from those that don't | declare now; placement enforcement not built |
+| **Encryption encouraged** | sealed content (A), sealed at rest (B), keys in browsers | the class you pick | rastrillo `crypto`, `vault`, `webauthn` are primitives; app-level E2EE is hand-built |
+| **Continuous security monitoring** | `carlos errors`, `carlos availability`, edge analytics, opt-in virus scanning of stores | nothing | live |
+| **Cost/performance efficiency** | idle instances hibernate to the bucket and cost nothing; the fleet is sized to concurrent use, not headcount | units that idle (smaller units idle more) | live |
+| **Wake in under 500 ms** | a request for a sleeping instance is held while it wakes | same | live |
+| **Replicated from the first byte** | every database streams to object storage; every wake is a restore | an instance with a database (central apps keep records in the store instead) | live |
+| **Export and erasure per tenant** | a tenant's data is its own database and its own objects, never rows mixed with others, so export is copying them and erasure is a bounded list | the unit matching who leaves together | export live (`carlos db pull`); erasure is a procedure you write: `instances delete` deliberately keeps the parked database, replica and store objects, so name each and how it goes |
+| **Residency per app or per instance** | an instance only wakes on a host in its region | per-unit instances, so each customer can be pinned | released, switched off on Carloku |
+| **Self-hostable, first class** | one binary, a bucket and DNS; same binary hosted, on a customer's boxes (`carlos fleets`), or on a self-run platform | nothing; licence class decides what customers may do | live |
+| **One codebase, every deployment** (vendors) | one instance per customer means on-prem is a fleet you administer, not a fork | per-customer instances | customer fleets live; Tito self-hosts in three regions |
+| **API- and agent-driveable** | actions opt in as tools that reach the same handler a form does | rastrillo `tools` (manifest-generated) | in rastrillo; no app has published tools yet |
+
+What a tenancy choice **costs** belongs in the same declaration:
+cross-unit queries are hard by design; a stateless centre makes writes
+dearer than a SQLite row; model C is one box big today; class A or B
+makes recovery and search product surface; class C puts confidentiality
+on operator policy and authorization, not mathematics.
+
+## 5. Worked choices
+
+Short, to show the reasoning. Each still needs its own declaration.
+
+- **A bookkeeping ledger per small business, synced nightly with the
+ bank.** One instance per business; model B (a stateless home lists the
+ business's ledgers). Class B: sealed at rest, the live instance holds
+ the key for the unattended sync, and the README says a live-host
+ compromise reads data. Nightly work is `carlos schedule`, so the
+ instance sleeps the rest of the day.
+- **A forge where people belong to several organisations.** One instance
+ per repository (amadan) or per organisation; model C, a stateless hub
+ with path routes and one session. Trust is per content tier: public and
+ private repos are server-readable (C), end-to-end encrypted repos are A.
+ Rendered markdown is the app's own code, so C's one-origin rule holds;
+ raw user-hosted HTML (pages) needs its own hostnames. One box big until
+ cross-box path targets ship.
+- **A support desk sold to companies, whose agents read customer email;
+ big customers want on-prem.** One instance per customer company. Class
+ C, argued from support load, repaid with origin splits (credentials on
+ one origin, customer-authored mail rendered on another). Licence L5 if
+ closed. On-prem is a customer fleet or a self-hosted platform following
+ your release channel: the same binary, not a fork.
+- **Team chat.** One instance per conversation or team; model D, a blind
+ home. Class A2 with the residue listed (membership, timestamps).
+- **A product site.** No instance at all: `carlos deploy --kind static`.
+ Class S.
+
+## 6. The tenancy declaration
+
+Write this into AGENTS.md (and the README's trade-offs) before code. It
+is the answer an agent planning a CARLOS app gives first.
+
+```
+Tenancy declaration: <app>, <date>, <who decided>
+One instance is: <the unit> (person : instance = 1:1 | N:1 | 1:N | N:N)
+Why this unit: <whose data; what fails together; who leaves together>
+Reached through: model A | B | C | D, or <own model, and why>
+Central app: none | home | hub; stateless, records in its store
+Workloads and class: instance = <S|A1|A2|A/pub|B|C>; sidecar = <โ€ฆ>; central = <โ€ฆ>
+Declared residue: <every plaintext surface, one per line>
+Keys live: <browsers | instance memory while awake | n/a>
+Hosting: Carloku | customer fleets | self-hosted platform
+Licence class: L1โ€“L5, or "not published yet"
+Benefits claimed: <each with its mechanism, from ยง4>
+Costs accepted: <each, plainly>
+Waiting on: <unshipped platform or rastrillo pieces relied on, by name>
+```
+
+## Common mistakes
+
+| Mistake | Reality |
+|---|---|
+| A Carloku account per customer | Accounts are the app author's tenancy on the platform. Customers are instances of your app. |
+| A `tenant_id` column in one shared instance | Gives up isolation, erasure, residency and hibernation per tenant. Make the tenant an instance. |
+| A stateful home or hub with its own SQLite | Central apps are stateless; records are objects in the store. |
+| The provisioning credential on the tenant app | Every instance would hold a token that can delete its siblings. It lives on the central app only. |
+| Trusting the hub's membership list for access | The instance's own roster decides. The hub's copy is a directory. |
+| One class for the whole product | Class is per workload. A sidecar or central app that sees plaintext gets its own letter. |
+| Claiming the platform enforces your class or residency | Neither is enforced on Carloku as of 2026-10-15. Declare; don't overclaim. |
+| Copying data between instances to share it | Sharing is membership. Data never crosses a boundary. |
+| A reporting job that opens every tenant's database | The centre reports what it owns or witnesses; instances push fixed-shape facts through an outbox; a person's cross-space view fans out in the browser โ€” ยง2 "Seeing across tenants". |
diff --git a/vendor/carlos-skills/getting-started/SKILL.md b/vendor/carlos-skills/getting-started/SKILL.md
new file mode 100644
index 0000000..a1cfa22
--- /dev/null
+++ b/vendor/carlos-skills/getting-started/SKILL.md
@@ -0,0 +1,463 @@
+---
+name: getting-started
+description: Use when someone wants a new app on CARLOS built and live with minimum decisions โ€” "start a CARLOS app", "put this on carloku", a first deploy to an oncarlos.com URL, code hosted on amadan, or when an agent needs the family's default stack (rastrillo, amadan, the carlos CLI) as a recipe rather than a menu. Not for weighing trust models, app shapes, or hosting options โ€” that is building-carlos-apps.
+---
+
+# ๐Ÿค– Getting started on CARLOS
+
+## Overview
+
+This skill is the recipe: empty directory to a live URL on the hosted
+platform, with every decision already made. The companion skill,
+**carlos:building-carlos-apps**, is the menu โ€” read it when the app needs
+a real trust-model, app-shape, or hosting decision. This one assumes the
+defaults below and does not stop to ask.
+
+The pieces, named once:
+
+- **CARLOS** is the application architecture (carlosframework.com): one
+ static Go binary, one fully-isolated instance per tenant (own process,
+ own SQLite file, own unix socket), many instances on one small box,
+ every database continuously replicated to object storage and idle
+ instances hibernating to it. A first app is one instance; what a tenant
+ is in a bigger product is building-carlos-apps' first decision.
+- **Carloku** (carloku.com) is the hosted CARLOS platform. Its console is
+ `https://console.carloku.com`. Carloku is the product brand; the CLI is
+ always `carlos`, never `carloku`.
+- **rastrillo** is the CARLOS web framework (module and canonical repo
+ `amadan.net/rastrillo/rastrillo` since v0.25.0; the GitHub mirror is
+ `github.com/rastrilloorg/rastrillo`, which `github.com/carlosframework/rastrillo`
+ redirects to; an app still on the old `github.com/carlosframework/rastrillo`
+ import path builds, but new code uses the new one; docs at
+ rastrillo.org). It postdates most models'
+ training data โ€” follow the recipe literally, invent nothing.
+- **amadan** (amadan.net) is the family's git forge: repos, branches,
+ discussions and CI, with AI agents as disclosed accounts. It is where
+ the code lives by default. Its CLI is `amadan`, and its house rules for
+ agents are a skill of their own (Step 2, "Put the code on amadan").
+- **The `carlos` CLI** is the whole operational surface. Every command
+ works for a member with zero infrastructure access; if a step seems to
+ need SSH, AWS, or a box, you have left the path โ€” stop and re-read.
+
+## The defaults already chosen
+
+| Decision | Default | Why |
+|---|---|---|
+| Language / framework | Go + rastrillo, one static binary | The family stack; the framework enforces the SQLite and money rules for you |
+| App shape | Server-rendered HTML, zero-JS baseline | The family default; the other shape is a decision (building-carlos-apps) |
+| Design system | Rastrillo UI and tokens, with a thin app CSS layer | Reuse shared controls and themes; custom CSS covers app layout and branding |
+| Storage | SQLite via GORM (`rastrillo/db`) | cgo-free driver, WAL pragma order, writer/reader pools โ€” `db.Open` owns all of it; ledgered migrations (`rastrillo migration generate`, applied once at boot), additive-only by family rule |
+| Amounts | integer cents (`form.ParseCents`) | A float never touches an amount |
+| Code hosting | amadan, a `private` repo | The family forge; CI runs the scaffold's `make ci` with no config |
+| Hosting | Carloku, `<app>.<sqid>.oncarlos.com` | Zero infra to run; certs, replication, hibernation all platform-side |
+| Versioning | git short sha (`v1` is fine for the very first ship) | House convention |
+| Channel | `edge` | A new app is born with a single channel of that name โ€” **edge IS production** (Paul, 2026-08-19; release pipelines v2 builds it in). Bake windows, passkeys, and approvals are per-channel opt-ins you add later via a pipeline |
+| Tenancy | One instance for the whole app, with in-app accounts (model A) | The simplest boundary; the escalation trigger below says when it is wrong |
+| Trust model | Honest server, class C: app data is server-readable, and the README says so | See "The one decision you must still record" below |
+
+## The one decision you must still record
+
+The family default is server-blindness ("if the server is compromised,
+the attacker gets nothing"), and rastrillo ships the family
+envelope (`rastrillo/crypto`) โ€” but E2EE is an architecture, not a
+package import: key custody, recovery, and search all become product
+surface. The honest default for a first app is
+**server-readable data, declared**: one line
+in the README under "Honest trade-offs" saying the server can read app
+data, dated, with the class letter (C; a static site is S). That
+satisfies the family's deviation rule (every deviation enumerated,
+justified, published).
+
+**Escalation trigger, not optional:** if the app will hold private
+personal content โ€” messages, health data, anything a person would call
+theirs โ€” stop here and read carlos:building-carlos-apps ("The
+decisions") before writing code. The same goes if **more than one
+customer, team or person will each own separate data** (a signup that
+gives each one their own space, a SaaS, a forge with organisations):
+read carlos:building-carlos-apps' tenancy reference first, because one
+shared instance with a customer column is the one shape CARLOS exists to
+avoid. Tenancy and trust are chosen on day one, not retrofitted.
+
+## What the person does, once
+
+An agent can do everything below except these. Ask for them up front, in
+one message, rather than discovering them one at a time:
+
+1. **A Keymail identity** (keymail.dev). Carloku and amadan both sign in
+ through it; there are no passwords.
+2. **Sign in to the Carloku console** (`https://console.carloku.com`).
+ That first sign-in is the account.
+3. **Approve the `carlos auth login` code** in that browser (Step 0).
+4. **On amadan:** sign in on the web and claim a namespace, log the CLI
+ in (`amadan auth login -hub https://amadan.net`), then create the
+ agent's account, give it the namespace and mint its token: `amadan
+ agent create <name>`, `amadan agent grant <name> <ns>`, `amadan agent
+ token create <name>` (amadan.net/docs/agents). The token is shown
+ once; the operator configures it into the agent's own amadan home
+ (`AMADAN_HOME`). amadan's docs don't yet say how that file is laid
+ out, so if `AMADAN_HOME=<dir> amadan auth whoami` doesn't answer with
+ the agent's `<name>~<keymail>` identity, stop and ask rather than
+ working as the operator.
+5. **Run an amadan CI runner** for the repo (amadan runs none itself).
+ A bare `amadan runner` serves every repo its identity can reach and
+ runs each job **as that user with no sandbox**, so recommend a
+ dedicated machine or OS account with its own runner identity (an
+ agent account granted only the runner role: `amadan agent grant
+ <runner> <ns> -role runner`, with its own `AMADAN_HOME`), scoped and
+ isolated: `amadan runner -repo <ns>/myapp -isolate bwrap` (Linux,
+ bubblewrap installed). Start it once the repo exists (Step 2), since a
+ `-repo` runner only learns names at start. Anyone who can push to the repo can run code there; never
+ point a runner at a repo whose writers you wouldn't hand a shell. The
+ machine needs Go and network access: the scaffold's `make ci` fetches
+ its pinned tools with `go run` (amadan.net/docs/ci).
+6. **DNS**, only for a custom domain: the record `carlos domains attach`
+ prints.
+7. **Approve anything billable.** There is no public Carloku price list
+ yet (2026-10-18), so check with the person before object storage,
+ email sending, or a schedule that keeps an instance awake.
+
+And ask, in the same message, the questions that change the build:
+
+- **One place, or a product many sign up to?** One gym, one club, one
+ team is this recipe. A product where each customer gets their own
+ space is building-carlos-apps' tenancy chapter first.
+- **How do people sign in?** The default is a magic link by email,
+ which needs email sending on a live app (Step 2). Email and password
+ is the alternative that needs no email.
+- **Does anything happen on a clock** (reminders, a nightly job)? That
+ is `carlos schedule` (building-carlos-apps, platform.md).
+- **Does it take money?** Payments are not in this recipe; stop and
+ read building-carlos-apps.
+- **Names:** the app name, the amadan namespace and repo, and any
+ custom domain.
+
+## Step 0 โ€” install and sign in
+
+Go **1.25 or newer** (rastrillo's `go.mod` says `go 1.25.0`). Then the
+three CLIs:
+
+```sh
+brew install carlosframework/tap/carlos
+# no brew (Linux): one pinned release, installed only if its sum checks out
+V=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
+ https://github.com/carlosframework/releases/releases/latest | sed 's#.*/##')
+B=carlos-linux-amd64 # or carlos-linux-arm64; macOS: carlos-darwin-<arch>
+U=https://github.com/carlosframework/releases/releases/download/$V
+curl -fsSLO "$U/$B" && curl -fsSLO "$U/SHA256SUMS.txt" \
+ && sha256sum --ignore-missing -c SHA256SUMS.txt \
+ && install -m 0755 "$B" ~/.local/bin/carlos # any dir on PATH; no sudo
+
+go install amadan.net/rastrillo/rastrillo/cmd/rastrillo@latest
+go install amadan.net/amadan/amadan/cmd/amadan@latest
+```
+
+Keep `carlos` fresh with `carlos update` (it needs a terminal; otherwise
+repeat the download).
+
+Create an account at `https://console.carloku.com` (passkey sign-in
+through Keymail โ€” no password), then connect the terminal:
+
+```sh
+carlos auth login --console https://console.carloku.com
+```
+
+Flags are written `--flag` (`-a`, `-c`, `-e`, `-v` are the short forms);
+the single-dash spellings still parse. The CLI prints a short code;
+approve it in the signed-in browser. Skip
+this and nothing breaks โ€” the first command that needs a login offers to
+run it right there. `carlos auth whoami` shows who you are and your
+account's **sqid** (a short public id like `bdf` โ€” it appears in your
+app's hostname; it is not a secret).
+
+## Step 1 โ€” claim the app
+
+```sh
+carlos apps create --app myapp
+```
+
+App names are unique per account, not globally. With exactly one app in
+the account, later commands infer `--app`; passing it explicitly is never
+wrong.
+
+**Static site?** You are nearly done โ€” skip to "The static path" below.
+
+## Step 2 โ€” the rastrillo app
+
+**Read rastrillo's `SKILL.md` first**, in full. From the web it is at
+`https://raw.githubusercontent.com/rastrilloorg/rastrillo/main/SKILL.md`
+(the mirror of `amadan.net/rastrillo/rastrillo`); once the module is
+downloaded it is
+`$(go env GOMODCACHE)/amadan.net/rastrillo/rastrillo@<version>/SKILL.md`,
+which matches the version you build against. It is the app story, the
+file to follow literally instead of framework source; the topic docs
+are at `https://rastrillo.org/docs/`, each also served as markdown
+(`/docs/<page>.md`). The worked reference is `examples/notes`.
+
+```sh
+go install amadan.net/rastrillo/rastrillo/cmd/rastrillo@latest
+rastrillo new myapp && cd myapp && go mod tidy && go test ./...
+```
+
+The scaffold is SKILL.md's shape (five files plus `migrations.go`),
+tests passing before you write a line:
+
+```
+internal/myapp/models.go plain GORM structs
+internal/myapp/migrations.go Schema and BootSchema; `rastrillo migration generate` writes the SQL
+internal/myapp/app.go migrate.Apply, sessions, identity plugin, chi router
+internal/myapp/handlers.go the owner-scoped CRUD
+internal/myapp/render.go embedded templates, flash/session-aware pages
+cmd/myapp/main.go Resolve -> db.Open -> App -> Serve
+```
+
+The gate, before every commit, is the scaffold's own `Makefile`:
+
+```sh
+make ci # vet, gofmt, staticcheck, govulncheck, gitleaks, tests, budgets, migration check
+```
+
+After changing a model, run `rastrillo migration generate` and read the
+SQL before committing; never edit a shipped migration.
+
+The three rules that keep the app safe (SKILL.md has the full set):
+every query touching user-owned rows goes through `scope.Owned` โ€”
+reads AND writes, transactions included (a row that isn't yours 404s,
+never 403s); never bind a request body onto a GORM model (explicit
+`map[string]any` + `.Select` allowlist); sessions/CSRF are defaults
+you opt out of, not machinery you assemble. Accounts come from an
+identity plugin โ€” the family default is `auth` (magic-link email that
+auto-upgrades to sign-in-with-Keymail when the address has a claimed
+inbox; works for every address), with `password` (email+password,
+rate-limited) as the classic alternative โ€” and either one is a few
+lines in `app.go`; guard routes with `sess.Require`. **On a live app the
+magic link has to be sent**: `carlos email enable --app myapp` (after
+the person approves) delivers `CARLOS_SMTP_*` to the instance, and
+`Mailer: mail.FromEnv("CARLOS", logger)` in the `auth` config uses it.
+Leave the mailer unset and every link goes only to the log, which is a
+development convenience, not sign-in.
+
+(The manifest generator โ€” `rastrillo new`, `manifest/*.toml`,
+`rastrillo generate` โ€” is the optional declarative path: declare a
+resource once and its store, screens, and locale keys are generated;
+`scope = "user"` (v0.11.0+) owner-filters the generated queries for
+user-owned resources. Mix declared and hand-written resources freely.
+building-carlos-apps' `references/rastrillo.md` covers it.)
+
+`rastrillo.Resolve` + `Serve` speak the platform's process contract โ€”
+your binary accepts `--socket <path> --db <path>` and serves
+`GET /healthz` and `GET /api/version`. **There is no `$PORT`**;
+instances listen on unix sockets the platform hands them. Do not
+hand-roll flag parsing.
+
+For the screens, use Rastrillo's `ui` partials and its attribute
+vocabulary (`<form rst-form>`, `rst-btn="primary"` โ€” not classes), and
+no `style` attributes or `<style>` blocks: the baseline CSP drops them
+silently. Load
+its vendored `tokens.css` before a small app stylesheet; use that layer for
+app layout, token overrides and components Rastrillo does not provide.
+Read [the design-system guidance](../building-carlos-apps/references/rastrillo.md#design-system-and-app-css)
+before writing templates or CSS, and follow the docs for the app's installed
+Rastrillo version. Keep shared controls and their interaction states intact.
+
+### Put the code on amadan
+
+Load amadan's house rules for agents before the first command there:
+`amadan agent skill -install`, or read
+`https://amadan.net/docs/-/agent-skill.md`. They cover posting as the
+agent account (never the operator's), the session block every write
+carries, and the worktree-and-branch habit. Work as that account, with
+`AMADAN_HOME` pointing at the credentials the person minted.
+
+```sh
+amadan repo create <ns>/myapp -tier private # tiers: amadan.net/docs/tiers
+git init -b main
+git config user.name <name> # the agent's identity, before the first commit:
+git config user.email <name>~<operator keymail> # repo-local, so it never borrows the person's
+git add -A && git commit -m "Scaffold myapp"
+git remote add origin https://amadan.net/<ns>/myapp
+```
+
+**Then have the person start the runner, and only then push.** amadan
+queues a job only if a runner has already polled *that* repo: a runner
+started with `-repo <ns>/myapp` polls it from the start, and one without
+`-repo` picks a new repo up within about a minute. Push before that and
+the commit gets no job. If it happens, `amadan ci status` on the commit
+says "No runner serves this repo"; pushing the same commit again does
+nothing, so push a new commit once the runner is up.
+
+```sh
+git push -u origin main # after the runner is up
+amadan ci status <ns>/myapp # the job, or why there is none
+```
+
+git asks for a password on a private repo: give it the agent's token
+(the username is ignored) through a credential helper, never in the
+remote URL. That first push of the scaffold is the one commit straight
+to main.
+After it, every change starts with
+`amadan start <branch> -prompt - -summary "..."`: worktree, branch,
+your git identity, push and description in one step. On amadan the
+branch **is** the pull request; there is no separate step to open one.
+
+CI needs no config: the runner runs the scaffold's `.amadan/ci.d/`
+steps, the same gate as `make ci` (amadan.net/docs/ci).
+
+Deploying stays a command you run (Step 3). If the person wants CI to
+deploy, that is a `deploy`-role service credential (building-carlos-apps,
+platform.md) placed in the environment of a runner that serves only this
+repo: an amadan job sees its runner's environment (bar `AMADAN_*`), so a shared runner
+would hand the credential to every repo it serves.
+
+## Step 3 โ€” first deploy
+
+Provision the instance (once), build for the boxes, deploy:
+
+```sh
+carlos instances enable --app myapp # opt-in; console pins your <sqid> domain
+carlos instances create --app myapp --host myapp.<sqid>.oncarlos.com --channel edge
+# substitute your real sqid (carlos auth whoami shows it) โ€” the host is typed in full.
+# --channel is optional since 2026-08-31 (a first instance follows the app's pipeline
+# entry, not stable), and naming it explicitly is never wrong.
+
+GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build \
+ -ldflags "-X amadan.net/rastrillo/rastrillo.BuildVersion=$(git rev-parse --short HEAD)" \
+ -o myapp-linux-arm64 ./cmd/myapp
+
+carlos deploy --app myapp --message "Publish the first working app" ./myapp-linux-arm64
+```
+
+Agents pass `--message` on deploys and other mutations that support it. Describe
+what is being changed in one concise sentence; the Activity entry records the
+version and target separately. Do not wait for the interactive prompt during an
+automated run. See [Activity descriptions](../building-carlos-apps/references/platform.md#describe-changes-in-activity).
+
+The platform's boxes are **linux/arm64** โ€” build exactly that, statically
+(`CGO_ENABLED=0`; cgo is why the framework uses `modernc.org/sqlite`).
+
+`carlos deploy` is ship + promote + watch in one motion: it uploads an
+immutable release, promotes it to your app's entry channel (`edge` โ€”
+for a fresh app, edge is production; the old three-rung ladder is now
+an opt-in pipeline), then follows the rollout box by box (on Carloku;
+elsewhere it watches the URL) until the new build serves, and prints `live https://โ€ฆ`. Set `carlos apps build-probe
+--app myapp /api/version` once so it asks the process which build it
+runs rather than trusting the `X-Carlos-Version` header. On first
+run it offers to remember the artifact path in `.carlos/config`
+(commit that file); after that, releasing is just `carlos deploy`, no
+arguments.
+
+### The static path
+
+No instance, no binary โ€” deploy the directory:
+
+```sh
+carlos deploy --app myapp --kind static --host myapp.<sqid>.oncarlos.com ./public
+```
+
+`--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 and channel from. That deploy attaches the host and writes the
+record; afterwards `carlos deploy` from the project dir resolves both on
+its own. Omitting `--host` on a first deploy is a refusal, not a default.
+
+**Do not run `carlos instances enable` or `create` here.** "No instance"
+is literal: a static site is served by the edge straight off the channel
+pointer, with no process to start and nothing to wake. Step 3's recipe is
+for binaries, and following it for a site builds a route that succeeds at
+every step and then fails every wake. This one command is the whole
+deploy.
+
+## Step 4 โ€” verify like the family does
+
+The deploy's own `live` line is the primary proof. To re-verify by hand:
+
+```sh
+curl -sI https://myapp.<sqid>.oncarlos.com | grep -i x-carlos-version
+carlos channels --app myapp # what each channel currently serves
+carlos logs --app myapp -f # merged app + platform timeline, no box access
+```
+
+Two traps, both paid for:
+
+- **A 200 is not a verification.** An app-shell route happily serves any
+ build ever shipped; only the `X-Carlos-Version` header (stamped after
+ the instance actually restarted) is proof. Verify against the thing you
+ changed, with the binary you built.
+- **Hibernation is the story, not a bug.** Idle instances doze; the first
+ request wakes them. A session in flight keeps the old binary until its
+ instance idles โ€” that is the bake working, not a deploy that failed.
+
+## Iterating
+
+| Want | Command |
+|---|---|
+| Release again | `carlos deploy --message "Fix invoice rounding"` (from the project dir) |
+| Config var | `carlos env set --app myapp --message "Update the app setting" KEY=value` (converges in seconds) |
+| Secret | `carlos secrets set --app myapp KEY=value` (sealed, never printed) |
+| Tail logs | `carlos logs --app myapp -f` |
+| Bounce the process | `carlos restart --app myapp --message "Restart after changing configuration"` |
+| Undo a release | `carlos rollback --app myapp edge` |
+| List releases | `carlos releases --app myapp` |
+| Custom domain | `carlos domains attach --app myapp www.example.com` โ€” it tells you the DNS records to create; certs are automatic once DNS points at the platform |
+| Review a branch | `carlos canary --app myapp ./myapp-linux-arm64` โ€” a branch-only channel and URL that cannot disturb production. If the app carries config, pass `--environment`, or the canary host serves GETs and fails every form |
+| Who is visiting | `carlos analytics --app myapp --set-count edge`, then `--range 7d` โ€” counted at the edge: no JavaScript, no cookie, no stored IP, so don't add an analytics script |
+| What is breaking | `carlos errors --app myapp --since 24h` โ€” your `error` log lines, grouped, no wiring at all. The 502/503/504s the edge saw are `carlos availability --app myapp` |
+| Keep it private | `carlos gate set --app myapp` โ€” one shared password in front of the hostname, for a prototype nobody should read yet |
+| Feature switch | `carlos features set --app myapp beta-ui=v2` โ€” the platform serves it, only your app reads it, and it lands with no restart |
+
+Everything above is a console-mediated write that boxes converge within
+seconds. There is no restart-by-SSH, no cert ceremony, no Litestream
+config: routing, TLS, replication, restore drills, hibernation, and
+restarts are the platform's job. If you find yourself hand-rolling any
+of those, stop โ€” you are rebuilding the platform under your app.
+
+## Conventions that still bind you
+
+The platform mechanized the infrastructure, not the discipline:
+
+- The gate (`make ci`) green before every commit, with `CGO_ENABLED=0`
+ builds throughout; add `rastrillo generate --check` to it only if the
+ app declares manifest resources.
+- Migrations are additive-only โ€” new code over an old DB must always be
+ safe. Never delete data to update.
+- Zero-JS first; when JS is earned, small ES modules, no bundler, no
+ build step, 300-line cap.
+- Worktree per session, on a branch, with regular commits pushed as you
+ go. Merge to main only once the work is done, a human has
+ approved it, and CI is green (on amadan, `amadan branch merge`: a merge
+ commit, or one commit with `-squash`; never squash by hand, which
+ amadan shows as a deleted branch) โ€” until the repo has CI there is no green
+ to wait for, so done + approved is the gate, and standing up CI (the
+ same gate as above, run for you) is early work rather than a
+ prerequisite nobody can meet. Deploy only what merged. Commit trailers
+ mark AI authorship (๐Ÿค– / `Co-Authored-By`).
+
+The full working conventions are building-carlos-apps'
+`references/process.md`.
+
+## Common mistakes
+
+| Mistake | Reality |
+|---|---|
+| Reading `$PORT` and calling `http.ListenAndServe` | The contract is `--socket <path> --db <path>` on a unix socket. `rastrillo.Resolve`+`Serve` handle it; hand-rolled servers must too. |
+| Building for the local machine | Boxes are linux/arm64. `GOOS=linux GOARCH=arm64 CGO_ENABLED=0`, always. |
+| A bare `First(&x, id)` on a user-owned row | Every such query goes through `scope.Owned` โ€” reads, writes, and transactions alike. The isolation test suite in `examples/notes` is the regression guard to copy. |
+| Binding a form onto a GORM model | Mass assignment. Explicit `map[string]any` + `.Select` allowlist, per SKILL.md. |
+| Hand-rolling a router, certs, or Litestream | Platform's job. Your app is one binary on one socket. |
+| Verifying with a 200 or a cached DNS answer | Only `X-Carlos-Version` is proof, on the canonical host, after the deploy watch. |
+| Waiting for an SSH step that never comes | Every step is a `carlos` command. A step that needs box access is a wrong turn (or a product gap to file โ€” not a workaround to build). |
+| Destructive migration "to clean up" | Additive-only, forever. |
+| Skipping the trade-offs line in the README | The trust default is legitimate only when declared. One dated line. |
+
+## When to leave this skill
+
+The moment the app needs a choice โ€” end-to-end or partial encryption, a
+feed/real-time client shape, self-hosting, fleets of your own boxes โ€” or a
+capability this recipe skips, such as sending email (`carlos email enable`)
+or work that has to happen on a clock (`carlos schedule set` โ€” an idle
+instance cannot run its own timer), both in platform.md, or **a product
+where each customer gets their own instance** (a signup flow that
+creates instances, one domain split by path) โ€” switch to **carlos:building-carlos-apps**. It starts with tenancy (what
+one instance is, the user models, the trust classes, and the benefits
+each earns), then holds the decision axes,
+the family evidence for each option, and the deeper references this
+recipe deliberately skips.