carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1# 🤖 Tenancy: what one instance is, and what that buys
2
3The first decision for any CARLOS app, before trust model, shape, hosting
4or identity (decisions.md). Every other benefit on carlosframework.com
5follows from it: isolation, hibernation, residency, erasure, taking the
6data home. Settle it on day one and write it down as a **tenancy
7declaration** (§6). Changing it later is a data migration across every
8instance, not an edit.
9
10Status as of 2026-10-18. Carloku serves platform v0.28.0; rastrillo is
11v0.28.0. Where a piece below is designed but not shipped, it says so.
12
13## 1. The boundary: what is one instance?
14
15**The instance boundary is the app's choice** (the family doctrine). No
16unit is the default. The gallery at carlosframework.com/apps runs all of
17these, and all of them are correct for their app:
18
19| One instance is… | Person : instance | Apps that do it |
20|---|---|---|
21| a person | 1 : 1 | Kass (a training log; a coach is a *member* of the client's instance), Slopbox (one account), Dineraya (a buyer) |
22| a conversation or space | 1 : N | Eleven (one chat) |
23| a mailbox | 1 : 1 | Keymail |
24| a team | N : 1 | Oficina (one per team, per app: docs, sheets), Correomona, an internal finance ledger |
25| a customer organisation | N : 1 | Tito (one per organiser account, plus a sandbox instance each), Dineraya (a merchant), Jelly (designed: one per signup) |
26| a community | N : N | Woodstar (a relay: founder plus invited members) |
27| a repository | N : N | amadan (one per repo; the hub holds only names and membership) |
28| a connection | 1 : 1 | a payments app with one instance per bank connection |
29| the whole app | N : 1 | Movement (one sign-up list); a rastrillo app out of the box |
30| nothing: no user data | none | static sites (carlosframework.com, carloku.com) |
31
32Most products run **more than one pattern**: a central app plus per-unit
33instances (Tito, amadan, Oficina, Slopbox, Eleven), or one
34binary in several roles (Dineraya: buyer, merchant, and one shared
35dispatcher).
36
37**Choosing the unit.** Ask, in order:
38
391. **Whose data is it?** The unit is the smallest group that owns data
40 together and would be harmed if it leaked to the next group.
412. **What should fail together?** A panic or a runaway query takes down
42 one instance. Choose the unit whose outage you can explain to one
43 customer.
443. **Who leaves together?** A unit's data is its own database and its
45 own objects, so exporting it is copying them and erasing it is a
46 bounded list (the platform keeps a deleted instance's data until you
47 remove it). Pick the unit a customer would ask to
48 export, delete or move to their own boxes.
494. **What crosses units?** Sharing is **membership**, never copying: a
50 person joins someone else's instance (a coach in a client's log, a
51 member in a team). If a feature needs to *query* across units
52 (reporting, search across all customers), the unit is too small or
53 that feature belongs in its own app. A central app answers "what
54 might I have access to", never "may I have this". A view across
55 tenants (a vendor's dashboard, a person's search over their own
56 spaces) has its own pattern: §2 "Seeing across tenants".
575. **Does it idle?** Instances that sit idle hibernate and cost nothing.
58 Small units that are used in bursts get the most from this.
59
60**The anti-pattern: tenancy by `WHERE` clause.** One instance, a
61`tenant_id` column, every customer in one file. It gives up isolation,
62per-unit erasure, per-unit residency and per-unit hibernation at once,
63and one missed `WHERE` exposes everyone. On CARLOS, isolation is a
64process and a file. Use one shared instance only when the whole app
65really is one unit (one team, one list).
66
67## 2. How people reach their instance
68
69Four recommended models, from the user-models design (approved
702026-09-28). They are recommendations, not rules: if none fits, choose
71your own and record why.
72
73| | Model | You sign in to… | Recommended when | Runs it today |
74|---|---|---|---|---|
75| 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 |
76| B | **Simple home** | *your* instance | each person or team owns a space that stands alone | Oficina, Kass (in-process), the bank-connection app |
77| C | **Hub** | a whole product | people belong to several spaces and move between them all day | Tito (subdomains and a proxy), amadan (path routes) |
78| 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 |
79
80- **A** is the least machinery: rastrillo `auth` or `password`, owner
81 scoping, and `idear` for roles and invitations. Its cost is that one
82 instance's blast radius is everyone in it, and it never grows into a
83 product with many independent customers.
84- **B** is a small central app where you sign in, see your instances,
85 add one, and open one. Each instance has its own hostname and its own
86 sessions, and still accepts ordinary sign-in on its own origin, so it
87 works if the home is down. The home hands you in with a signed,
88 single-use assertion (rastrillo `assertion`, issuer the home, audience
89 the instance), posted in a form, never in a URL. Smallest leak profile
90 of the recommended models: the home knows who signed up and which
91 instances they own, nothing inside any of them. Cost: no shared chrome;
92 moving between spaces goes through the home.
93- **C** puts the product on one domain and each tenant under a path
94 (`example.com/acme`) via the platform's path routes. One origin, one
95 session, one switcher; every tenant still its own process and file.
96 Costs: one origin is one browser security boundary, so use C only when
97 every tenant runs the app's own code (a product whose tenants publish
98 raw HTML their customers wrote needs B's per-tenant hostnames). The
99 hub's session-signing key is the product's crown jewel. **Today a path
100 target must live on the same box as the hostname**, so a path-routed
101 product is one box big.
102 How it fits together: the tenant receives `/acme/...` unchanged, so the
103 tenant app serves everything (routes, assets, redirects, form actions)
104 under its base path, and its own direct hostname redirects to the
105 product path. Because hub and tenants share one origin, there is one
106 session: the hub mints a short-lived signed cookie (15 minutes, with a
107 per-user epoch so "sign out everywhere" works), and tenants hold the
108 hub's public key (`carlos env set`) and verify it on every request,
109 never minting their own. Ordinary per-tenant rastrillo sessions do not
110 work under model C: every tenant would set the same cookie name on the
111 same origin and sign the others out. The hub delivers roster changes to the
112 instance first, with a signed request, then records its directory
113 copy.
114- **D** is B's shape for server-blind apps: the home holds credential
115 public keys and one sealed blob per person and can read neither.
116
117**Central apps are stateless** (ruled 2026-09-27, a foundational CARLOS
118principle). A home, hub, directory or sign-in service keeps no database:
119its records are objects in its own store (`carlos store create`), created
120with `If-None-Match: *` and updated with `If-Match`, at keys it can
121compute (`users/<uid>.json`, `tenants/<slug>.json`). Nothing to
122replicate, restore or migrate, and it moves between boxes with nothing
123to copy. Anything relational (reporting, search) belongs in an instance.
124
125**Safety invariants, whichever model:**
126
1271. The provisioning credential (`carlos services create --role instance
128 --app <tenant-app>`) lives on the central app, never on the tenant app.
129 It is app-wide today: it can also delete any sibling instance. Publish
130 that as a leak until the platform ships a create-only role. The same
131 goes for any `instance`-role token (`CARLOS_INSTANCE_TOKEN`): never put
132 one in the tenant app's secrets, whatever the convenience.
1332. An instance decides access from its own roster. A signature proves
134 who, never what.
1353. Handoff tokens never ride in URLs, and the receiver enforces single
136 use.
1374. Model C only where every tenant runs the app's own code.
1385. Every model publishes its leak profile (§3).
139
140**What is real today, and what is not** (2026-10-15):
141
142| Piece | State |
143|---|---|
144| Instances API, `instance` service role, path routes, `assertion`, `vault`, `idear`, stores | Shipped. Use them. platform.md "Provisioning instances from your own app" |
145| Create-only, quota-bounded provisioning role | Not built (platform #185). Use the `instance` role and name the leak |
146| Path targets on another box | Not built (platform #186). Model C is one box big |
147| 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 |
148
149### Seeing across tenants
150
151A vendor wants totals across every customer; a person wants to search
152every space they belong to. Tito runs both with one rule: **the centre
153reports only what it owns or witnesses. It never opens a tenant's
154database.** Four ways data legitimately crosses the boundary, in order of
155preference:
156
1571. **Witnessed facts.** Make the number a fact the vendor owns (a fee, a
158 charge, usage) and record it where the vendor already sits on the
159 path: the proxy, payment egress, mail egress, a payment provider's
160 webhook. Tito's staff console gets collections, request timings and
161 capacity this way, from facts its router, payment relay and the
162 payment provider saw for themselves. Fees *owed* are partly computed
163 from facts instances report, which is step 2.
1642. **Pushed facts through an outbox.** Where only the instance knows, it
165 writes a fixed-shape record (a fee accrual, a ticket count) to an
166 outbox table in the same transaction as the work, and a sender
167 delivers it to the central side, retrying until accepted. The
168 instance is usually asleep, so send at the end of the request that did
169 the work, and let anything undelivered go on the next wake or a daily
170 `carlos schedule` tick (a tighter schedule keeps the instance awake,
171 and that is billed). Retries mean duplicates, so each record carries a
172 stable id; the receiver dedupes on it in the same transaction as the
173 ledger write, acknowledges only after commit, and refuses a different
174 payload under an id it already holds (Tito does all four for fees). The record's
175 shape is declared residue, published like any other. The receiver
176 files each record under the instance that sent it, so **an instance
177 can only speak for itself**. Short self-descriptions (name, region,
178 counts) can also ride on responses the centre already proxies.
1793. **Browser fan-out for a person's own view.** A search or switcher
180 across someone's spaces asks each instance they are a member of,
181 from the browser. Every instance answers as it would any other request
182 from that person: membership, then their role's capabilities and any
183 narrower scope (Tito filters by the events and series a member may
184 see), so a result list never shows what the person could not open. No
185 central copy; nothing widens the set beyond real memberships. Tito
186 does this on one origin (model C), where the one session already
187 reaches every tenant. Across per-tenant hostnames (model B) each
188 request needs that origin's own session, so there it is usually a
189 link per space instead. Every request can wake a sleeping instance,
190 so Tito answers typing from a cache the browser built from each
191 instance's own answers (refreshed when that account's digest changes),
192 and asks the instances live only when the person asks for more
193 results, at most four at a time.
1944. **One tenant at a time, with consent.** Support access is granted from
195 inside the tenant (in Tito, by a member, never above their own role,
196 and capped at admin), time-limited, and logged before the first read,
197 failing closed if the log write fails. Never aggregation.
198
199**Where the vendor's records live.** Not in the router, hub or home. A
200**back office app** with its own instance and database holds the
201vendor's ledger, partners and statements, receives the outbox, takes
202signed identity like any tenant instance, and asks the central app for
203anything else through a narrow API (list accounts, create one). Tito's
204console holds these in its router today; its proposed split moves them
205into a back office app of this shape.
206
207**Not allowed:** a central app or reporting job opening tenant databases
208(`carlos db pull` across every instance is a backup of everyone, not a
209report); copying tenant content (buyers, messages, documents) into a
210central store; a `tenant_id` warehouse.
211
212**The sender problem (2026-10-16).** Step 2 is only as good as the
213receiver's knowledge of which instance sent a record. It must derive
214that from a credential bound to the sending instance and verified by the
215receiver, never from a name the sender supplies, and never from the
216app-wide `instance` token, which every holder can present for any
217sibling. Tito today trusts box-local sockets plus a header the instance
218sets, so a compromised instance could report as a sibling. The family has
219no ready-made per-instance sender credential on Carloku: platform tokens
220are app-wide (#155, not built), and even scoped platform tokens would
221not by themselves authenticate an instance to *your* receiver. Until a
222pattern exists, cross-check pushed facts against what you witnessed
223where you can (a fee against the provider's webhook), keep pushed
224records to facts a lying instance gains little from, and name the gap as
225a leak.
226
227## 3. What the host can see: the trust class
228
229Tenancy says where the walls are; the trust class says what a host can
230read inside them. Pick a class per **workload**, not per product: an
231end-to-end encrypted app with an automation sidecar is two answers (Eleven
232and Kass: instance A2, agent sidecar C). Classes order S ≤ A < B < C; the
233A variants are flavours, not grades.
234
235| Class | The host holds | Examples |
236|---|---|---|
237| **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) |
238| **A1** host-blind | ciphertext; only transport shape leaks (who connects when, sizes, timing) | Slopbox (padded blobs, encrypted names) |
239| **A2** host-blind, declared residue | ciphertext plus a published list of plaintext metadata | Keymail (routing headers), Kass (log timestamps), Eleven (thread membership), Dineraya buyer |
240| **A/pub** public by design | A, plus surfaces that are public on purpose, listed one by one | Woodstar (public posts; DMs and blobs sealed) |
241| **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 |
242| **C** host-sighted | plaintext at rest, chosen and written down: support visibility, SQL over personal data, payments | Tito (the defining example) |
243
244Several apps have not published a class letter yet (amadan's is
245unresolved; Oficina, Movement and Correomona describe their
246posture in words, without a letter). Don't assign one on their behalf.
247
248**The test is the CARLOS one: publish the database and see what leaks.**
249A leaks its declared residue and nothing more; B's cold copy leaks
250nothing; C leaks the data, and said so up front. Enforce it in CI: push a
251marker through the app, then grep the raw SQLite bytes and bucket objects
252for it. An undeclared plaintext surface is a class violation whatever the
253intent. decisions.md §1 has each app's recorded reasoning.
254
255**Placement follows the class (designed, not yet enforced).** Hosts are
256tiered by who controls the box: 0 the operator, 1 a named counterparty
257under a data-processing agreement, 2 a stranger. S and A run anywhere, B
258never on tier 2, C only on tier 0 or where the agreement names the data.
259As of 2026-10-15 the platform does not record or enforce trust classes,
260so declare the class in your README and AGENTS.md now; don't tell users
261the platform enforces it.
262
263**Two more axes, independent of the class:** a **licence class** (L1
264libre, L2 source-available commercial, L3 open with a hosted offering, L4
265closed hosted-only, L5 closed hosted and on-prem: Tito, Jelly) and three
266**remix flags** (forkable, agent-driveable through typed tools,
267agent-rebuildable from a published skill). An L5 app can be class A; an
268L1 app can be class C.
269
270## 4. What the choice buys
271
272The benefits on carlosframework.com, each with the mechanism behind it
273and what it depends on. State a benefit only with its mechanism, and only
274the ones your tenancy actually earns.
275
276| Benefit | Mechanism | Depends on | State on Carloku, 2026-10-15 |
277|---|---|---|---|
278| **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 |
279| **Blast radius of one** | a panic or runaway query kills one process; the next request restarts it | same | live |
280| **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 |
281| **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 |
282| **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 |
283| **Continuous security monitoring** | `carlos errors`, `carlos availability`, edge analytics, opt-in virus scanning of stores | nothing | live |
284| **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 |
285| **Wake in under 500 ms** | a request for a sleeping instance is held while it wakes | same | live |
286| **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 |
287| **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 |
288| **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 |
289| **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 |
290| **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 |
291| **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 |
292
293What a tenancy choice **costs** belongs in the same declaration:
294cross-unit queries are hard by design; a stateless centre makes writes
295dearer than a SQLite row; model C is one box big today; class A or B
296makes recovery and search product surface; class C puts confidentiality
297on operator policy and authorization, not mathematics.
298
299## 5. Worked choices
300
301Short, to show the reasoning. Each still needs its own declaration.
302
303- **A bookkeeping ledger per small business, synced nightly with the
304 bank.** One instance per business; model B (a stateless home lists the
305 business's ledgers). Class B: sealed at rest, the live instance holds
306 the key for the unattended sync, and the README says a live-host
307 compromise reads data. Nightly work is `carlos schedule`, so the
308 instance sleeps the rest of the day.
309- **A forge where people belong to several organisations.** One instance
310 per repository (amadan) or per organisation; model C, a stateless hub
311 with path routes and one session. Trust is per content tier: public and
312 private repos are server-readable (C), end-to-end encrypted repos are A.
313 Rendered markdown is the app's own code, so C's one-origin rule holds;
314 raw user-hosted HTML (pages) needs its own hostnames. One box big until
315 cross-box path targets ship.
316- **A support desk sold to companies, whose agents read customer email;
317 big customers want on-prem.** One instance per customer company. Class
318 C, argued from support load, repaid with origin splits (credentials on
319 one origin, customer-authored mail rendered on another). Licence L5 if
320 closed. On-prem is a customer fleet or a self-hosted platform following
321 your release channel: the same binary, not a fork.
322- **Team chat.** One instance per conversation or team; model D, a blind
323 home. Class A2 with the residue listed (membership, timestamps).
324- **A product site.** No instance at all: `carlos deploy --kind static`.
325 Class S.
326
327## 6. The tenancy declaration
328
329Write this into AGENTS.md (and the README's trade-offs) before code. It
330is the answer an agent planning a CARLOS app gives first.
331
332```
333Tenancy declaration: <app>, <date>, <who decided>
334One instance is: <the unit> (person : instance = 1:1 | N:1 | 1:N | N:N)
335Why this unit: <whose data; what fails together; who leaves together>
336Reached through: model A | B | C | D, or <own model, and why>
337Central app: none | home | hub; stateless, records in its store
338Workloads and class: instance = <S|A1|A2|A/pub|B|C>; sidecar = <…>; central = <…>
339Declared residue: <every plaintext surface, one per line>
340Keys live: <browsers | instance memory while awake | n/a>
341Hosting: Carloku | customer fleets | self-hosted platform
342Licence class: L1–L5, or "not published yet"
343Benefits claimed: <each with its mechanism, from §4>
344Costs accepted: <each, plainly>
345Waiting on: <unshipped platform or rastrillo pieces relied on, by name>
346```
347
348## Common mistakes
349
350| Mistake | Reality |
351|---|---|
352| A Carloku account per customer | Accounts are the app author's tenancy on the platform. Customers are instances of your app. |
353| A `tenant_id` column in one shared instance | Gives up isolation, erasure, residency and hibernation per tenant. Make the tenant an instance. |
354| A stateful home or hub with its own SQLite | Central apps are stateless; records are objects in the store. |
355| 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. |
356| Trusting the hub's membership list for access | The instance's own roster decides. The hub's copy is a directory. |
357| One class for the whole product | Class is per workload. A sidecar or central app that sees plaintext gets its own letter. |
358| Claiming the platform enforces your class or residency | Neither is enforced on Carloku as of 2026-10-15. Declare; don't overclaim. |
359| Copying data between instances to share it | Sharing is membership. Data never crosses a boundary. |
360| 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". |
361