carlos / todomvc-bakeoff Public
chore: bakeoff repo, brief, contract and shared TodoMVC assets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed,
+3898
−0
.gitignore+1 −0AGENTS.md+37 −0BRIEF.md+143 −0CONTRACT.md+105 −0apps/.keep+0 −0results/.keep+0 −0shared/base.css+141 −0shared/index.css+393 −0shared/reference.html+68 −0tests/behaviour/package-lock.json+76 −0tests/behaviour/package.json+7 −0vendor/carlos-skills/SOURCE+1 −0vendor/carlos-skills/building-carlos-apps/SKILL.md+329 −0vendor/carlos-skills/building-carlos-apps/references/blueprint.md+267 −0vendor/carlos-skills/building-carlos-apps/references/decisions.md+227 −0vendor/carlos-skills/building-carlos-apps/references/platform.md+870 −0vendor/carlos-skills/building-carlos-apps/references/process.md+192 −0vendor/carlos-skills/building-carlos-apps/references/rastrillo.md+218 −0vendor/carlos-skills/building-carlos-apps/references/tenancy.md+360 −0vendor/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. |