eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– The cloud provisioner β€” self-serve space creation
2
3Status: **shipped** (server side). The app UI ("+ β†’ Sign in β†’ new space") is
4deliberately not built yet; this doc covers everything behind it.
5
6## The idea
7
8Creating an instance used to be an operator SSHing to the box and running
9`lchat add-instance`. The end state we want: a member signed in to their home
10server taps **+** in the app and gets a fresh, fully-isolated Eleven space β€”
11address, TLS, admin-granting setup link β€” in a few seconds. Two pieces make
12that possible:
13
14- **The cloud provisioner** (`lchat cloud`, e.g. at `cloud.elevenmessenger.com`)
15 can create instances on its box, gated by caller tokens, per-user quotas, and
16 a box capacity limit. It's a subcommand of the same binary that runs the
17 router, so **any host running Eleven can enable it** and get multi-instance
18 auto-provisioning β€” it isn't infrastructure only we can run.
19- **The home server** gains one endpoint (`POST /api/spaces`): a signed-in user
20 asks *their* home server for a space; home forwards the request to the cloud
21 provisioner it's paired with and relays the result.
22
23```
24app ── link token ──▢ home ── caller token ──▢ lchat cloud ── control token ──▢ lchat controld ──▢ systemctl…
25 (lchat-home unit) (lchat user, sandboxed, (root; local unix socket
26 public via the router) /run/lchat/control.sock)
27```
28
29The client then opens the returned **setup link** (`/join/setup#<T>`), claims
30it, becomes the space's admin, and runs the normal setup conversation. Adding
31the new space to the user's encrypted backup is the client's move, exactly like
32any other server β€” the blob stays content-blind.
33
34## The privilege split
35
36Creating an instance needs root (systemctl, writes to `/opt/lchat`), but
37everything network-facing on the box runs as the unprivileged `lchat` user
38under a strict systemd sandbox. So provisioning is two processes:
39
40- **`lchat controld`** (`lchat-control.service`, **root**) β€” the god surface
41 `instance-router.md` reserved: `POST /instances` on a local unix socket,
42 never routed publicly. It executes the same creation core as
43 `lchat add-instance` (`addInstance` in `control.go`), serialized, and is the
44 single authority on **capacity** (below).
45- **`lchat cloud`** (`lchat-cloud.service`, `lchat` user, sandboxed) β€” the
46 public policy layer: caller tokens, per-user quotas, friendly slug
47 generation. It holds no root powers; everything it can do to the box, it does
48 by asking controld.
49
50### Who holds which token
51
52| Token | Lives | Grants |
53|---|---|---|
54| control token | `/etc/lchat/control.token`, **root-only 0600**; handed to the cloud unit alone via systemd `LoadCredential` | create instances on the box |
55| cloud admin token (`LCHAT_ADMIN_TOKEN`) | `/data/cloud.env` | mint/list/revoke caller tokens |
56| caller token | the paired home server's env (`LCHAT_CLOUD_TOKEN`) | provision spaces, bounded by quota |
57| device link token | the user's device | ask *their* home server for a space |
58
59The root-only control token matters because **every service on the box shares
60the `lchat` uid** β€” socket permissions can't distinguish the cloud service from
61a compromised instance. A compromised instance can read `/data` (a known,
62accepted box-level exposure), but it cannot read the control token, so it
63cannot mint instances or reach the creation path. `lchat bootstrap` mints the
64token idempotently.
65
66## Capacity: the box says no before it falls over
67
68controld enforces two limits, checked atomically with each create
69(`LCHAT_MAX_INSTANCES`, default **40**; `LCHAT_MIN_FREE_GB`, default **5**):
70
71- **Instance ceiling.** The educated guess for a 4 GB box: the OS, router,
72 push, home, and the existing fleet use ~1 GB; an idle instance is ~25–40 MB
73 (no server-side crypto or media work, by design), so 40 spaces β‰ˆ 1.4 GB with
74 ~1.5 GB headroom for spikes. When Go bot sidecars land (~25 MB each, vs
75 ~120 MB for today's Node sidecar), 40 spaces *with* bots still fits.
76- **Disk floor.** Disk, not RAM, is the long-run binding resource (attachments
77 are blobs in SQLite), so creation also refuses when `/data` has less than the
78 floor free.
79
80Past either limit the API answers `503 {error: "at_capacity"}` and the cloud
81layer relays the member-facing message: *"We can't create a new Eleven space
82right now β€” please try again later."* Clients show it verbatim and stay calm.
83
84### Hibernate-by-default creates (`LCHAT_CONTROL_HIBERNATE_CREATES=1`)
85
86With this env set on `lchat-control` (off by default β€” the flip is a per-box
87config decision, not a code deploy), every provisioner-created space is born
88**hibernating** (#550): the create registers the route, writes the env file,
89stages the binary β€” but starts **no systemd unit and no process**. The
90create-path readiness probe is the space's one *first wake* (it mints the setup
91link and warms the cert through the router's activator); after the idle window
92the activator puts it to sleep, and a never-claimed space costs **zero RAM**
93from then on. It requires the **router** to be running with
94`LCHAT_HIBERNATE_BUCKET`, or created spaces never answer. Direct
95`lchat add-instance` keeps its explicit `--hibernate` flag either way.
96
97Under the flag the capacity policy changes shape:
98
99- **`LCHAT_MAX_INSTANCES` counts *materialized* instances only** β€” the
100 systemd-managed, always-on ones that actually hold RAM. Hibernating rows
101 don't consume it, so the cap stops being the constraint on self-serve
102 creation.
103- **`LCHAT_MAX_REGISTERED`** (default **10Γ— `LCHAT_MAX_INSTANCES`**) is a
104 sanity ceiling on *total* instance rows, hibernating included β€” registry
105 rows, env files, and staged symlinks aren't free forever.
106- The **disk floor** applies unchanged; disk stays the real long-run bound.
107
108## Addresses
109
110Spaces get generated friendly slugs β€” `amber-fox-42.<base-domain>` β€” from small
111embedded wordlists (collisions re-roll; a reserved blocklist keeps sibling
112names like `home`/`push`/`cloud` unmintable). The display name of the group is
113set in the setup conversation; the hostname is just an address, and hostnames
114are public in certificate-transparency logs regardless. User-chosen slugs (or
115custom domains) are a possible later feature, deliberately not v1.
116
117### Addressing (sqids)
118
119A space's subdomain label is **`sqids(id)`** β€” the sqid of its integer primary
120key in the `spaces` table. So `a4f.<base-domain>` both *is* short and *encodes*
121the id reversibly: `spaceID("a4f")` recovers the id, i.e. "which server this is."
122SQLite supplies the id (`INTEGER PRIMARY KEY AUTOINCREMENT`); Go's `sqids-go`
123does the encoding (lowercase base36, min length 3 so labels grow only as ids do,
124default blocklist so nothing rude is minted, `AUTOINCREMENT` so a torn-down
125space's id β€” and thus its label β€” is never reused). Creation reserves the id
126first (quota-checked), turns it into the hostname, then creates the instance;
127there's no wordlist and no collision re-roll, because distinct ids give distinct
128labels by construction.
129
130### Certs + readiness
131
132The base domain (`LCHAT_CLOUD_BASE_DOMAIN`) needs **wildcard DNS** pointing at
133the box. Certs are per-host (autocert HTTP-01) unless the box is configured for a
134wildcard cert (DNS-01 β€” see `instance-router.md`). Either way, provisioning
135**confirms readiness through the public path before returning the setup link**:
136after the instance passes its own socket health check, `add-instance` issues an
137HTTPS request *through the local router* (dialed at `:443` with the new host's
138SNI) and waits for a non-5xx reply. That guarantees the moment a human clicks the
139setup link they reach a ready admin sign-on β€” never a mid-provision error β€” and,
140on a per-host-cert box, warms the cert as a side effect (the handshake triggers
141issuance) so there's no first-hit "not secure" race. Best-effort: on timeout it
142returns anyway (the instance already health-checked; the public path is a beat
143behind at most). The home UI keeps a spinner up for the whole call, so the
144"Create a new space" button only reveals the link once the space is confirmed up.
145
146A wildcard record also means typo'd or retired subdomains resolve to the
147router. Set the router's `LCHAT_FALLBACK_URL` (e.g. the apex marketing site) so
148those redirect somewhere useful instead of 404ing. On a per-host-cert box an
149unknown host fails TLS before any redirect can happen (no cert is ever minted
150for it), so the redirect only covers `:80` and hosts whose cert outlived their
151route β€” but with the **wildcard cert** (DNS-01, `instance-router.md Β§ Wildcard
152TLS`) any subdomain of the base domain completes the handshake, so garbage
153subdomains land on the fallback over https too.
154
155## API
156
157### cloud (`lchat cloud`)
158
159```
160POST /api/instances bearer: caller token {requester: <opaque id>}
161 201 {host, url, setup_link}
162 409 {error: quota_exceeded} per (caller, requester) FREE-space quota,
163 default 1 (LCHAT_CLOUD_QUOTA; paid spaces
164 never count, and upgrading frees the slot)
165 503 {error: at_capacity, message} the box is full
166GET /api/instances?requester=… this requester's spaces (+ entitlement/tier,
167 and a top-level billing price book)
168POST /api/instances/{host}/checkout bearer: caller token
169 {requester, tier, success_url, cancel_url}
170 200 {url} the hosted Stripe Checkout page
171 400 unknown tier | 404 not yours | 409 already_upgraded | 501 billing off
172POST /api/instances/{host}/portal bearer: caller token {requester, return_url}
173 200 {url} the hosted Billing Portal (cancel/card/invoices)
174 404 not yours | 409 not_subscribed | 501 billing off
175POST /api/stripe/webhook signature-gated (Stripe calls it; see Billing)
176POST /api/admin/tokens bearer: admin token {label} β†’ {id, token} (shown once)
177GET /api/admin/tokens list (never the token values)
178DELETE /api/admin/tokens/{id} revoke
179```
180
181### controld (`lchat controld`, local socket only)
182
183```
184POST /instances bearer: control token {host, admin_token?, …env fields}
185 201 {host, url, setup_link, admin_link}
186 400 invalid_host | 409 host_exists | 503 at_capacity | 500 create_failed
187```
188
189### home
190
191```
192POST /api/spaces bearer: device link token
193 β†’ relays the cloud response (201 / 409 / 503 shapes above)
194 403 {error: not_eligible} caller has no saved space yet (see gate below)
195 501 {error: unavailable} this home server has no provisioner configured
196GET /api/spaces bearer: device link token
197 β†’ the hosts this user provisioned (drives the "Delete space" affordance)
198DELETE /api/spaces/{host} X-Ceremony-Proof: fresh passkey ceremony ONLY
199 β†’ relays cloud's 200 / 404
200POST /api/spaces/{host}/checkout bearer: device link token {tier}
201 β†’ relays cloud's 200 {url} / 400 / 404 / 409 / 501; return URLs point back at home
202POST /api/spaces/{host}/portal bearer: device link token
203 β†’ relays cloud's 200 {url} / 404 / 409 / 501 (Manage on a paid space's row)
204```
205
206πŸ€– **Per-country provisioning (optional, `LCHAT_CLOUD_REGIONS`).** By default
207home talks to one provisioner (`LCHAT_CLOUD_URL` + `LCHAT_CLOUD_TOKEN`). Setting
208`LCHAT_CLOUD_REGIONS` β€” a JSON array of `{code,name,url,token}` β€” plus
209`LCHAT_CLOUD_DEFAULT_REGION` lets a signed-in user's space be placed in one of
210several country provisioners (the dev mesh runs `ie` + `fi`; see
211`docs/dev-mesh.md`). It supersedes the single `LCHAT_CLOUD_URL`; the default
212region's provisioner also backs demo and every host-agnostic fallback. Routing:
213`POST /api/spaces` takes an optional **`region`** (empty = auto-assign to the
214default; an unknown code quietly auto-assigns rather than failing); `GET
215/api/spaces` fans out across every region and merges, so a user sees spaces they
216hold anywhere; host-specific calls (delete, checkout, portal) route by the region
217label the host encodes (`<sqid>.<code>.…`). `GET /api/features` advertises the
218regions β€” `code` + `name` only, never a url or token β€” so the create UI can offer
219the choice; single-region home omits the key. (The create-page region control is
220a follow-on; today the choice rides the `region` field.)
221
222Creation and listing are link-token-authed (additive/reads) and creation is
223rate-limited per IP; the real abuse bound is the provisioner's quota. **With
224`LCHAT_POW_BITS` set on home (default off), creation also demands hashcash**:
225`POST /api/spaces/challenge` mints a sealed, expiring, single-use challenge
226bound to the account; the client grinds SHA-256(challenge:nonce) to that many
227leading zero bits during the fader's build phase and submits it with the
228create. Verification is one hash; a consumed challenge never buys a second
229space. This prices the Sybil loop (invite yourself β†’ fresh account β†’ 3 more
230free spaces) in CPU.
231
232Above all of that sits the **fleet-wide creation budget**
233(`LCHAT_CLOUD_MAX_CREATES_PER_DAY`, 0/unset = uncapped): a trailing-24h
234circuit breaker counted against an append-only log, so create-and-delete
235can't reset it. Past the budget, creation answers the same member-facing
236at-capacity message. This is the pre-production insurance dial β€” the
237per-account gates do the fine-grained work; this bounds a bad day.
238
239On bot detection generally, the position is deliberate: **passkeys are the
240bot check**. Creating an account requires a platform-authenticator WebAuthn
241ceremony; there is no fingerprinting, UA sniffing, or behavioral scoring
242anywhere, and there won't be β€” the layers are cryptographic (passkeys),
243economic (hashcash, storage caps), and quantitative (quotas, rate limits,
244the budget, the reaper). **Creation
245is also gated to existing members:** `POST /api/spaces` returns `403 not_eligible`
246unless the caller already has a backup blob. A blob only exists once you've
247enrolled β‰₯1 space, and enrolling needs a space you were *invited* into β€” so this
248keeps creation invite-gated (not open to the world) while it's early, without the
249server ever reading the E2E-encrypted server list. The client hides the "+" when
250the list is empty; the 403 is the boundary behind that. Lift it later by removing
251the check (the invite-only funnel becomes self-serve). **Deletion is
252ceremony-gated** β€” same rule as revoking devices or deleting the backup: a
253stolen link token must never be able to destroy a space. The UI adds its own
254stern step (type the space's address to arm the button) before the ceremony,
255and the cloud layer re-checks ownership against its ledger regardless: only the
256exact (caller, requester) that provisioned a host can delete it, so spaces
257added to a list by URL are never deletable through this surface. "Remove from
258list" (vault-only, the space keeps running) is a separate, lighter client-side
259action. On the box, deletion **archives** the instance's DB/env to
260`/data/trash/<host>-<unix>/` rather than destroying them β€” the operator safety
261net until the trash is purged.
262
263## What this changes about who-sees-what
264
265Provisioning is the one place the home server (and the provisioner's ledger)
266learns a relationship between a user and an instance hostname β€” `user X
267provisioned host Y`. That's **operational metadata, held outside the blob**,
268and it's deliberate: quotas need a ledger. The requester id is the home
269server's random opaque uid (no email, no name), the blob stays content-blind,
270and a user who never provisions never appears in the ledger. Members who join
271by invite are invisible here, unchanged.
272
273## Reaping never-claimed spaces
274
275Self-serve creation hands out a capacity slot the instant a space is made. A
276space whose admin never finishes setup β€” the setup invite expires after 7 days β€”
277would otherwise sit there forever: empty, but holding a slot and a disk. The
278**reaper** (in `cloud`, `reaper.go`) reclaims them:
279
280- Every `LCHAT_CLOUD_REAP_INTERVAL_HOURS` (default 6) it sweeps the ledger for
281 finalized spaces older than `LCHAT_CLOUD_REAP_AFTER_DAYS` (**opt-in β€” 0/unset
282 disables it**; a value comfortably past the 7-day invite expiry, e.g. 14, is
283 the intent).
284- For each candidate it asks the control daemon `GET /instances/{host}/stats`,
285 which reads the instance DB **read-only** and returns `setup_complete`, member
286 count, and `has_content`. A candidate is reaped only when **all three guards**
287 agree it's an abandoned shell:
288 1. **`setup_complete` absent** β€” the admin never finished the setup conversation.
289 (The signal is `setup_complete`, **not** member count: `add-instance`
290 pre-seeds the admin user so the setup invite has an identity, and claiming
291 *becomes* that user β€” so a space has exactly one member whether or not it was
292 ever set up.)
293 2. **`members <= 1`** β€” nobody beyond the seeded admin joined.
294 3. **`has_content` false** β€” zero messages and zero threads. The instant anyone
295 posts anything, the space is protected regardless of the other signals; this
296 is the decisive guard against a false positive on a used-but-not-marked space.
297
298 **Anything set up, joined, with content, or whose status can't be read is left
299 untouched** β€” a stats error is always a skip, never a delete.
300- A reaped candidate is deprovisioned through the normal controld delete, which
301 **archives** the DB/env to `/data/trash` (never destroys), so a false positive
302 is recoverable. Its ledger row is cleared, freeing the quota slot.
303- **Per-sweep cap:** `LCHAT_CLOUD_REAP_MAX_PER_SWEEP` (default 5) bounds how many a
304 single sweep reclaims; the rest defer to the next sweep. This caps the blast
305 radius of any future mis-classification to a handful of recoverable archives
306 before an operator would notice.
307- `LCHAT_CLOUD_REAP_DRY_RUN=1` logs exactly what it *would* reclaim without
308 deleting β€” run it once before arming the real sweep.
309
310Because the reaper only ever considers hosts **in the cloud ledger**, operator-run
311instances (family.…, home, push, …) are never candidates.
312
313### Self-reclaim: a quota-blocked create frees the account's own dead weight
314
315With the free quota at 1, a fumbled create (made a space, lost the tab, never
316set it up) must not lock the account out until the reaper's 14-day sweep. So
317when a create hits `quota_exceeded`, the cloud first tries to reclaim **one of
318the requester's own abandoned allocations** and, if that frees a slot, proceeds
319with the create instead of failing:
320
321- Candidates are only the same (caller, requester)'s free-entitlement rows
322 **older than one hour** β€” setup takes minutes, so the age gate cleanly
323 separates "abandoned, retrying" from "setting it up in another tab right
324 now" (the home page keeps the setup link for continuing inside the window).
325- A **pending row** (a create that died between allocate and finalize β€” it
326 would otherwise hold the slot forever) is cleared ledger-only.
327- A **provisioned space** must clear the reaper's exact three guards above
328 (never set up, nobody joined, zero content); it archives to `/data/trash`
329 through the same controld delete. A stats error is a skip, never a reclaim.
330- Anything in real use stays untouched and the 409 stands β€” the way out then
331 is upgrading a space (paid spaces don't count) or deleting one from home.
332
333**Hibernating spaces never wake for the reaper.** Stats always come from local
334state, not from the instance: an *asleep* space is read from its on-disk DB (the
335warm cache the activator left at its last sleep β€” on one box, the newest truth),
336and a space that **never woke** has no DB at all, so its stats are definitional β€”
337`members 0, setup_complete false, has_content false`, answered from the registry
338row alone. A sweep over thousands of dormant spaces stays a pure metadata read.
339Reaping a hibernating space follows the same delete path (unit-stop becomes
340best-effort β€” there is no unit; the route delete + router reload retires any
341awake child via the activator's reconcile) and archives to `/data/trash` as
342usual. The space's **S3 replica prefix is deliberately not deleted**: controld
343holds no bucket credentials, the prefix is opaque ciphertext of a DB whose
344authoritative copy just moved to trash, and the versioned bucket's
345noncurrent-version expiry means it ages rather than grows β€” orphan-prefix
346cleanup is deferred to the S3 lifecycle work.
347
348A companion **trash purge** in `controld` reclaims the archive after a longer
349window: `LCHAT_TRASH_KEEP_DAYS` (opt-in β€” 0/unset keeps trash forever) removes
350`/data/trash/<host>-<unix>` entries older than that, swept daily. Keep it well
351above the reap threshold so a mistaken reap stays recoverable for a while.
352
353## Demo spaces (docs/demo-spaces.md slice 1) β€” 24h, discarded, no backups
354
355With `LCHAT_DEMO_TTL_HOURS` set (unset = the shipped default, demos off), a
356create may carry `kind: "demo"`: an `entitlement='demo'` ledger row that
357skips the free-space quota and is bounded instead by `LCHAT_DEMO_MAX_LIVE`
358(default 10, pending rows included β€” refusals answer `demo_busy`) plus the
359shared creates-per-day breaker. The create response carries `expires_at`.
360Three deliberate inversions of the free-space rules:
361
362- **Reaped unconditionally** at `created_at + TTL` by an hourly demo sweep β€”
363 no stats call, no guards: content protects a free space, never a demo (the
364 24h death is the disclosed contract). `LCHAT_CLOUD_REAP_DRY_RUN` is
365 honoured. The main reaper never sees demos (its candidate query is
366 free-only).
367- **Discarded, not archived**: the reap deletes via controld
368 `DELETE /instances/{host}?discard=1`, which removes the files outright
369 (blobs included) instead of renaming into `/data/trash` β€” the one exception
370 to archive-everything.
371- **Never activator-managed**: controld forces `Hibernate=false` for a demo
372 create even on a hibernate-by-default box, so a demo never touches S3 β€” no
373 lease, no litestream replica, no parked prefix. It holds a materialized
374 slot for its 24h life; the live-demo cap bounds how many.
375
376Demos take a `demo` key in `LCHAT_CLOUD_STORAGE_CAPS` (falling back to the
377`free` cap when absent).
378
379**The home-side gates** (`home/demo.go`, on with `LCHAT_HOME_DEMO=1` β€”
380default off): `POST /api/demo/challenge` + `POST /api/demo`, the one
381account-free creation path, so the graduated-friction ladder is the whole
382boundary β€” **mandatory adaptive hashcash** (base `LCHAT_DEMO_POW_BITS`,
383default 18; +1 bit at 5 creates in the trailing hour and another per
384doubling, with the difficulty sealed inside each challenge so escalation
385never voids honest work in flight), and **one demo per IP bucket per
386cycle** (IPv6 at /56; blocked from creation until expiry + one full TTL
387again, so serial recreation converges to one rolling slot). Buckets are
388HMACs under a per-process random key β€” no raw IP is ever stored, nothing
389links across a restart, and a failed create never burns the bucket. The
390client IP comes from the router's `X-Forwarded-For` stamp.
391`GET /api/features` advertises `demo` so clients can show the button only
392where it works. **The home surface**: the sign-in card offers "Try a demo
393space" (feature-gated, with the honest one-liner about the 24 hours), the
394client grinds the hashcash inline β€” the burn is the wait β€” and lands in
395the fresh space's setup link same-tab. `POST /api/spaces/claim`
396(link-token) relays a claim to the cloud so a signed-in account can adopt
397a demo as its free space; folding the claimed space into the encrypted
398backup is the client's move, like a create. Still to come: the in-space
399countdown/claim banner (an instance-side surface).
400
401**Claiming** (`POST /api/instances/{host}/claim`, caller-token): the happy
402exit β€” a signed-in account adopts a demo, transferring ownership and
403flipping `entitlement` to `free` in one guarded UPDATE (a racing claim or
404reaper loses cleanly). The claim consumes the account's free-quota slot
405(same rules as a create, including the courtesy self-reclaim), refuses
406expired demos (`410 demo_expired` β€” past-TTL is dead by contract even
407before the sweep), and re-pushes the instance's entitlement env + free
408storage cap + replication class. Claims are never pool-gated: the space's
409spend was already counted while it was a demo. Flipping the claimed space
410onto the activator (hibernation + S3 backups) is an operator/follow-up
411step β€” the claim itself never restarts anything.
412
413## The storage meter (#668 slice 1)
414
415Every sweep interval (`LCHAT_CLOUD_METER_INTERVAL_HOURS`, default 6, 0
416disables), the cloud service asks controld for each ledger space's on-disk
417footprint β€” the SQLite files plus the attachment blob dir, **pure filesystem
418metadata: nothing is read, and a hibernating space is never woken**. The
419reading lands on the ledger row (`storage_bytes`, `metered_at`), rides the
420space listing, and home shows it to the member ("2.1 GB stored"). Bytes of
421ciphertext are exactly the kind of thing this ledger is allowed to know
422(#562: price only what ciphertext exposes).
423
424**Storage caps (#561, insurance-grade).** With `LCHAT_CLOUD_STORAGE_CAPS` set
425("free=2GB,family=5GB,…" β€” tier keys from the price book plus `free`), the
426cloud pushes each ledger space's cap into its instance env
427(`LCHAT_BLOB_QUOTA_BYTES`, via controld `POST /instances/{host}/quota`), where
428the instance's own upload guard enforces it: uploads past the cap answer 507,
429nothing is ever deleted, and text messaging is never gated. Pushes happen on
430tier changes (checkout/subscription webhooks), at creation, and the meter
431sweep reconciles drift β€” self-healing, best-effort at every step. A
432unit-managed instance is bounced (`try-restart`, seconds) so the cap applies
433now; a hibernating one picks it up at next wake β€” never wake a space to limit
434it. Unset = no enforcement, and operator-run instances (not in this ledger)
435are never touched. Caps follow the 80/20 margin
436rule in `tier-economics.md` (an allowance is what 20% of a tier's net buys
437in storage, forever) and always match the published allowance table.
438Business is per-seat (`business=11GB/seat` β€” cap = 11 GB Γ— the space's live
439head count, pooled); a big team's pool exceeds the box's disk β€” honored on
440demand (attach a Hetzner volume) until attachments move off-box (#550).
441
442## The usage pipe (#857, #668 slices 2–3) β€” AI usage into the ledger
443
444With `LCHAT_CLOUD_USAGE_URL` set (the cloud's own public `/api/usage` URL),
445the same meter sweep mints each ledger space a **meter token** and pushes it
446into the instance env (`LCHAT_METER_URL` + `LCHAT_METER_TOKEN`, via controld
447`POST /instances/{host}/meter`, hash-only in the ledger, same self-healing
448best-effort shape as the quota push). The flow is sidecar β†’ instance β†’ cloud:
449
450- The **sidecar** counts model spend β€” it's the only place spend is visible β€”
451 as (day, model, dimension) quantities: `llm_requests`, the four token
452 counters (input / output / cache-write / cache-read, priced differently),
453 and `charter_runs`. It flushes every few minutes to its instance
454 (`POST /api/bot-usage`, bot-token-gated), keeping its egress surface at the
455 two destinations `docs/bots.md` promises.
456- The **instance** is a stateless relay: validate, forward with the meter
457 token, hand the response's budget state back to the sidecar. Unconfigured
458 (self-hosted, BYO key) it accepts and drops β€” answer `"unmetered"`, nothing
459 recorded anywhere.
460- The **cloud** upserts into `usage(space_id, day, model, dimension,
461 quantity)` β€” quantities only, never content, never per-member attribution
462 (#668's rule) β€” and answers a budget state, `"ok"` unconditionally until
463 the phase-3 allowance/budget math lands (docs/ai-provisioning.md).
464
465Unset `LCHAT_CLOUD_USAGE_URL` = the pipe is off fleet-wide: no tokens minted,
466no env pushes, instances answer sidecars `"unmetered"`.
467**Replication classes (the durability ladder).** With
468`LCHAT_CLOUD_REPLICATION_CLASSES` set
469(`free=daily,family=daily,community=batched,business=stream` β€” same key
470convention as storage caps), the cloud maps each ledger space's billing state
471to a replication class on the activator's S3 replica and pushes it via
472controld (`POST /instances/{host}/replication` β†’ the registry route + a router
473reload, only on actual change). Same event-driven-plus-sweep shape as caps:
474spaces are **born classed** (the create request carries the free class),
475Stripe webhooks re-push on every entitlement move (a lapse drops the space
476back to the free rung), and the meter sweep reconciles drift. The class is
477wake-time config β€” nothing is restarted, no sleeping space is woken, and an
478awake instance keeps its current sync tick until it next sleeps. An unknown
479**paid** tier maps to `stream` on purpose: a config typo must fail toward
480durability, never away from what the member paid for. Unset = no pushes,
481every space streams (the pre-ladder status quo). What the classes mean β€”
482sync cadence, loss windows, and the graceful-flush guarantee β€” lives with
483the mechanism in `registry.go` and the ops guide.
484
485## The free-pool gauge (docs/free-pool.md slice 1)
486
487The free pool runs the free tier as a budget: free + demo spend must never
488exceed a configured share of total cost, funded by paid revenue with an
489operator floor. **Slice 1 is a gauge only** β€” it computes and records, gates
490nothing, and ships off by default. At the end of each meter sweep the cloud
491service estimates every ledger space's daily cost from a declared cost model
492(`LCHAT_POOL_COSTS`, defaults = the docs/free-pool.md snapshot), derives
493
494```
495budget_day = min( X% Γ— total_cost_day, max(floor, Y% Γ— net_revenue_day) )
496pressure = free_cost_day / budget_day
497```
498
499and records one `pool_meter` row (pruned past ~400 days) plus a log line.
500Knobs: `LCHAT_POOL_MAX_SHARE` (X β€” **0/unset = gauge off**),
501`LCHAT_POOL_REVENUE_SHARE` (Y, default 30), `LCHAT_POOL_BASE_EUR_DAY`
502(default 0.20), `LCHAT_POOL_DEMO_SHARE` (default 20, reserved for the demo
503tier). Revenue comes from an additive `net_eur_year` field per tier in
504`LCHAT_STRIPE_PRICES` (entries without it fund the pool as €0 and log a
505startup warning). `GET /api/pool` (admin token) serves the latest reading β€”
506labelled an estimate, because it is one: derived from declared constants,
507not metered.
508
509**Slice 2 β€” admission control.** At `pressure β‰₯ 1` on the latest reading,
510new FREE creates answer `503 free_pool_full` with the honest member-facing
511message (home's 503 passthrough relays it verbatim). The gate **fails
512open** on any uncertainty β€” pool off, no reading yet, ledger error β€” and
513never touches anything else: demos are exempt (their carve-out + TTL bound
514them), paid creates are never gated (they fund the pool), existing spaces
515are never touched, and deleting your own space still works at red.
516`LCHAT_POOL_DRY_RUN=1` computes and logs every would-refuse without
517refusing β€” arm it the reaper's way: gauge first, dry-run second, live
518third. Budget + pressure now use revenue **smoothed over the trailing ~90
519days** of stored readings (one big churn shrinks the pool gradually); each
520`pool_meter` row still records the instantaneous figure.
521
522## Billing (Stripe) β€” tiers, quota exemption, no metering
523
524Optional space upgrades (`billing.go`): a person who created a space can pay a
525subscription to upgrade it at one of a small set of **tiers** (Family / Friends /
526Business β€” config, not code). Per-tier powers (storage, member counts, bots) and
527metering are #561/#562 and layer on later; today a paid space of ANY tier gets
528the same two things.
529
530**The flow.** The home page shows a quiet *Upgrade* on spaces this account
531created (only when the provisioner advertises billing). It opens a plan picker
532(the price book from the listing response); the chosen tier key goes home β†’
533cloud, which mints a Stripe **Checkout Session** for that tier's price; the
534browser goes to Stripe's hosted page and comes back to home
535(`?upgraded=<host>`). Stripe then confirms through the **webhook** β€”
536`checkout.session.completed` flips the space's ledger row to `paid` and records
537the tier, and `customer.subscription.updated/deleted` track it for the life of
538the subscription (`past_due` keeps access through Stripe's dunning window;
539deletion drops the space back to `free`). The row's numeric ledger id rides in
540the session's `client_reference_id` and the subscription's metadata, the tier
541key in both metadata blobs β€” that's how events route back to a space.
542
543Creating a space itself is **never pay-gated** (#1374): the create step is
544just the name, and "who is this space for?" is asked by Eleven inside the
545space, priced from the cloud's `GET /api/pricebook` (caller-token'd; home
546republishes it unauthenticated and CORS-open at its own `/api/pricebook`,
547cached, for space origins to read). The create screen's tier cards appear
548only when a create bounces off the free quota or the pool gate β€” the two
549errors paying genuinely fixes; picking one there runs the pay-first
550checkout-create path below.
551
552**What paying does today:**
553
554- a paid space **doesn't count toward the creation quota** (`allocateSpace`
555 counts only free spaces β€” upgrading a space frees its slot, and every extra
556 space carries itself);
557- a paid space is **never a reap candidate** (excluded in the ledger query
558 itself, the strongest of the guards above);
559- the entitlement + tier come back on space listings so clients render the
560 badge. Per-space limits read the same columns when #561 lands.
561
562**Privacy (the point):** the ledger stores two opaque Stripe ids
563(`stripe_customer_id`, `stripe_subscription_id`), the `entitlement` flag, and
564`paid_until`. **Never an email, name, address, or card detail** β€” Stripe holds
565those on its side, and the operator can see them in Stripe's dashboard.
566Paying is not anonymous; the invariant (#562's documented residual) is that
567the *ledger* carries no billing identity. Checkout Sessions are created with no customer
568fields at all. The webhook is the one public unauthenticated-by-token endpoint;
569its gate is Stripe's HMAC signature over the raw payload (Β±5 min replay window).
570
571**Config** (`/data/cloud.env`; all-or-nothing β€” partial config refuses to start):
572
573```
574LCHAT_STRIPE_SECRET_KEY restricted API key (write: Checkout Sessions; read: nothing else needed)
575LCHAT_STRIPE_WEBHOOK_SECRET the webhook endpoint's signing secret
576LCHAT_STRIPE_PRICES the price book: a JSON array of tiers, e.g.
577 [{"key":"family","name":"Family","label":"$5/year","price":"price_…"}, …]
578 (display strings live here verbatim β€” the server never
579 asks Stripe what a price costs, so a Stripe outage can't
580 block a cloud restart; the checkout charges the real price)
581 Optional per-tier: "eur" (numeric sticker, feeds the
582 picker's β‰ˆ local-currency display), "business": true
583 (ex-VAT tier: checkout collects a VAT ID), and "bands"
584 β€” the honor prices of docs/pricing-bands.md:
585 {"supporter"|"reduced"|"solidarity":
586 {"label","price","eur","net_eur_year"}}.
587 A tier without bands behaves exactly as before; a
588 price id may appear only once in the whole book.
589LCHAT_STRIPE_API_BASE default https://api.stripe.com (tests point it at a stub)
590LCHAT_STRIPE_AUTOMATIC_TAX "1" opts Checkout Sessions into Stripe Tax
591 (docs/pricing-bands.md Β§ VAT). Flip it WITH the
592 band-price rollout β€” every live Price must carry a
593 tax_behavior (or an account default) first.
594```
595
596Unset = billing off: checkout answers 501, listings say `billing: false`, the
597UI shows nothing. The Stripe-side webhook endpoint must subscribe to
598`checkout.session.completed` and `customer.subscription.*`, pointed at
599`https://<cloud host>/api/stripe/webhook`.
600
601**Managing a subscription** is Stripe's hosted **Billing Portal**: Manage on a
602paid space's row β†’ home β†’ cloud mints a portal session for that space's own
603Stripe customer (same ownership gate as checkout). The portal also offers
604**plan switching** (upgrade/downgrade, monthly↔annual, prorated by Stripe);
605the webhook derives the tier from the subscription's current PRICE (metadata
606only seeds the first value), so the badge and the pushed storage cap follow a
607switch automatically. **Cancelling** is at-period-end: the space keeps its
608tier until the paid time runs out, then the deletion event drops it to free β€”
609the space survives, its storage cap returns to the free allowance (existing
610content is never deleted; only new uploads pause if over), and it counts
611toward the creation quota again. Portals are per-space by
612construction β€” every space is a separate Stripe customer, deliberately (no
613cross-space identity exists to merge). `LCHAT_STRIPE_PORTAL_CONFIG` optionally
614pins a portal configuration id; unset uses the account's default (which must
615exist β€” save the portal settings once in the Stripe dashboard, or create a
616configuration via the API). With bands, `LCHAT_STRIPE_PORTAL_CONFIGS`
617(JSON `{band: configuration id}`) picks a per-band configuration β€” each
618band's portal offers plan switches inside that band only; band changes go
619through the home surface, where they can be messaged. Bands without an entry
620fall back to `LCHAT_STRIPE_PORTAL_CONFIG`.
621
622## Enabling it on a box
623
624```sh
625lchat bootstrap # installs units, mints /etc/lchat/control.token
626systemctl enable --now lchat-control
627cat > /data/cloud.env <<EOF # then: chown lchat:lchat, chmod 600
628LCHAT_ADMIN_TOKEN=<mint one>
629LCHAT_CLOUD_BASE_DOMAIN=<domain with wildcard DNS at this box>
630LCHAT_CLOUD_REAP_AFTER_DAYS=14 # optional: reclaim never-claimed spaces (0/unset = off)
631EOF
632systemctl enable --now lchat-cloud
633# optional trash purge (controld): set LCHAT_TRASH_KEEP_DAYS in /data/control.env, restart lchat-control
634lchat add-service cloud.<domain> --socket /run/lchat/cloud.sock
635# mint the home server's caller token:
636curl -sX POST https://cloud.<domain>/api/admin/tokens \
637 -H "Authorization: Bearer $ADMIN" -d '{"label":"home.<domain>"}'
638# hand it to home: LCHAT_CLOUD_URL + LCHAT_CLOUD_TOKEN in /data/home.env, restart lchat-home
639```
640
641## Phase 2 (designed for, not built)
642
643- **App UI** β€” "+ β†’ Sign in (home.elevenmessenger.com) β†’ new space", and the
644 server list syncing/sorting through the existing encrypted blob.
645- ~~**Bot sidecars on provisioned spaces**~~ β€” SHIPPED (#858): controld's
646 `POST /instances/{host}/bot` stages an **Eleven-only** sidecar (idempotent;
647 the model key comes from controld's own env, never the instance's). The
648 trigger is the admin's AI opt-in: setup's dedicated `ai-optin` step (or any
649 later flip of the AI toggle) makes the instance signal
650 `POST /api/ai-optin` on the cloud over its meter token, and the cloud calls
651 controld. Hibernating hosts stage the env only β€” the activator co-spawns at
652 wake, and its sweep adopts an already-awake child within seconds. Meter
653 tokens are now also minted eagerly at create, so a fresh space can opt in
654 during onboarding without waiting for the sweep.
655- **Multi-box placement** β€” a cloud front choosing among several router boxes.
656