carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Temporary static sites
2
3Status: rev 9, accepted by Astra round 9 (2026-09-25), approved by Paul 2026-09-26. Rulings in §1 are Paul's and are fixed.
4Revs 2-9 answer Astra rounds 1-8
5(`CODEX-FINDINGS-TEMPORARY-SITES-R{1..8}.md`). §§10-17 disposition
6each finding; the text marks fixes **(astra N)**, **(astra 2.N)** and so
7on.
8
9## 0. Why
10
11Two uses want a site that cleans itself up:
12
13- an LLM scratchpad: an agent deploys a directory, looks at it, iterates,
14 and walks away;
15- a one-shot page for a customer: documentation, a proposal, a report
16 that matters for a week.
17
18Today both become permanent apps. Nothing expires them, there is no
19storage quota (only the 256 MiB per-ship cap, `apiShipMaxBytes`), and an
20agent in a loop can leave hundreds of apps behind. The opposite risk is
21as real: a real site marked temporary by mistake must never quietly
22vanish.
23
24## 1. Rulings (fixed)
25
261. **The unit is the whole app.** Not a host on an existing app, not a
27 release.
282. **Expiry trashes; purge follows after a short window.** At expiry the
29 app goes to the Trash exactly as `carlos apps delete` would put it
30 there (routes drop, `apps restore` works). A temporary app is purged
31 3 days after it was trashed, not 30.
323. **Bounded while live.** TTL defaults to 7 days, maximum 30. An account
33 holds at most 20 live temporary apps; past that a new one is refused,
34 never evicted.
354. **`carlos apps keep`** makes a temporary app permanent; **`carlos apps
36 ttl`** moves its expiry.
375. **Temporary apps get the platform host only.** No custom domains.
386. **Only a brand-new app can be made temporary.**
397. **Temporary apps live in a reserved `tmp-` namespace** (Paul,
40 2026-09-25, after Astra round 6). Every temporary app is named
41 `tmp-<label>`. Nothing permanent may be *created* under a `tmp-` name.
42 A kept app keeps its `tmp-` name.
438. **Temporary creates have their own budget of 50 per principal per
44 day** (Paul, 2026-09-26, raised while planning). It is separate from
45 the permanent create budget of 10 a day (`authcli.go:119`), which
46 would refuse a scratchpad agent at its 11th app. The live cap (20)
47 bounds storage while apps are live. This budget bounds churn: a
48 trashed temporary app holds storage for its 3-day window, so the
49 worst case is about 150 trashed-but-unpurged apps per principal.
50
51## 2. Where the state lives: the console, not the bucket
52
53Rev 1 put a signed `expiry.json` in the bucket and had boxes act on it.
54Every P1 in round 1 traced back to that: a signed record that authorises
55destruction can be confused with another signed record (astra 1),
56replayed after it is revoked (astra 5), copied by migration (astra 10),
57deleted mid-purge (astra 9), and raced by writers that share no lock
58(astra 3, 4).
59
60Rev 2 keeps the lifecycle **in the console's `apps` table** and makes the
61console the only actor that expires or early-purges anything.
62
63- **Boxes learn nothing new.** They see a trash marker, drop routes, and
64 their existing auto-purge still runs at 30 days
65 (`reconcile.go:226-238`). For a temporary app that pass is a backstop
66 the console normally beats by 27 days. No box code changes.
67- **Nothing in the bucket can make an app temporary**, so bucket write
68 access cannot shorten a permanent app's purge window, and there is no
69 record to forge, replay or copy.
70- **All transitions serialise in one place**: a per-app mutex in the
71 console process plus conditional `UPDATE`s on the row (§5).
72- **Every transition settles first** **(astra 3.1, 3.3)**. Under the
73 mutex, before acting, each door and pass calls `s.settleApp`, which
74 reads the app's tombstone and brings the row into line with it: the
75 one-app form of `MaterializeApps`, extended by §5 and §6. A crash
76 between a bucket write and its row write is therefore repaired by the
77 next thing that touches the app, not only at console start
78 (`materializeQuietly` runs once, `server.go:1278`). After settling,
79 the door re-reads the row and re-checks its own preconditions; a
80 candidate list taken before the lock is only a hint.
81- **Absence is never evidence on its own** **(astra 4.1)**. Every branch
82 below that reasons from a missing marker and missing objects to "the
83 purge finished" and calls `PurgeAppRow` first requires the
84 empty-bucket tripwire `MaterializeApps` already has
85 (`materialize.go:296`, `:388`): a successful listing of `apps/` that
86 is **not empty**. An empty or failed listing means "no evidence"; the
87 row is left exactly as it is and the pass logs the same warning
88 materialize does. The tripwire is factored out of `MaterializeApps`
89 into one helper that both use. The expiry pass runs it once per pass;
90 a door runs it only if it reaches an absence branch.
91- **A tripped pass writes nothing it cannot justify** **(astra 5.2,
92 7.3)**. If the tripwire fails at the start of an expiry pass, the pass
93 does no expiry trashes and no absence finalisation. It **does** still
94 retry early purges, because `PurgeAppIfTrashedAt` acts only on the
95 *presence* of a marker whose `deleted_at` matches the row's
96 `purge_for` to the second: that match is evidence of the right
97 bucket, and a wrong, empty bucket has no such marker (it gets
98 `ErrNotTrashed`, which is an absence branch and waits). This matters
99 when an early purge of a deployment's last app fails on its final
100 marker delete: the marker is then the only key left, the tripwire
101 trips on every pass, and without this exception nothing would retry
102 it. When `PurgeAppIfTrashedAt` succeeds, the pass calls `PurgeAppRow`
103 in the same step, so a successful purge never needs the absence
104 branch to finish. Otherwise the
105 pass's own trash markers would be the only thing in a wrong, empty
106 bucket, and the next pass would read them as evidence the bucket is
107 real. For the same reason the tripwire counts only keys that are
108 **not** trash markers (`deleted.json`): markers are the one thing
109 this feature writes into a bucket that holds nothing else. What is
110 left is the corner `MaterializeApps` already accepts in its comment
111 (`materialize.go:311` onward): other writers, like a user's deploy,
112 landing in the wrong bucket. This spec does not widen it.
113
114This assumes one console per deployment bucket, which is how every
115deployment runs today. If the console is down, nothing expires and
116nothing early-purges; both fail toward keeping data.
117
118New columns on `apps` (migration in `store.go`):
119
120| Column | Meaning |
121|---|---|
122| `expires_at INTEGER NOT NULL DEFAULT 0` | 0 = permanent. Unix seconds otherwise. |
123| `restoring INTEGER NOT NULL DEFAULT 0` | 1 from just before a restore deletes the marker until the row is live again (§6). Counts against the cap. |
124| `purge_after INTEGER NOT NULL DEFAULT 0` | 0 = no early purge. Set only when a temporary app is trashed. |
125| `purge_for INTEGER NOT NULL DEFAULT 0` | The tombstone `deleted_at` (Unix seconds) that `purge_after` was computed from. |
126
127`purge_after` and `purge_for` are fixed **at trash time**, from whether
128the app was temporary at that moment. Nothing later re-derives them.
129
130## 3. Creation: a fresh claim, never an adoption
131
132Temporary apps are created through a **new route**,
133`POST /api/cli/apps/{account}/new-temporary` `{"name": "...",
134"ttl_seconds": N}`, that shares `handleAPICreateApp`'s body
135(`apicli.go:359` onward) but not its request type. A separate route, not
136a field on `/new`, because `/new`'s decoder accepts unknown fields
137(`authcli.go:940`): an older console would take `temporary` silently
138and create a permanent app. An older console answers the new route with
139404, and the CLI stops there with nothing created **(astra 2.7)**. The
140CLI also checks the response carries a non-zero `expires_at` before it
141ships.
142
143The route takes no `control_plane`, `place` or `build_probe`; a
144temporary app is never the control plane **(astra 2.8)**. On this route
145**(astra 2)**:
146
147- `claimApp` runs with `ownerClaim = false` regardless of role. An app
148 name with **any** object under its prefix (and, for ops, the legacy
149 prefix) is refused (`server.go:3487` onward). Shipped-but-unpromoted
150 releases count, because the check is "any key", not "any pointer".
151 Together with the namespace and route checks below, this is the brand-new test: `claimApp` already checks the row,
152 the apex hold and the prefixes.
153- **The name is in the `tmp-` namespace** (ruling 7) **(astra 6.1,
154 6.2)**. This is what makes "brand new" provable. Routes are tied to
155 apps by name alone, the console cannot see hand-added routes on other
156 boxes (`apex.go:74`), and reconcile can re-stamp a route with
157 whichever account solely owns the name in the bucket
158 (`reconcile.go:723-735`, before the trash drop at `:189`/`:206`). No
159 inventory the console can read proves a name unused across the fleet.
160 A reserved namespace makes the question unnecessary: no permanent
161 app, row or route can hold a `tmp-` name, so a temporary app's expiry
162 can only ever drop routes a temporary app created. The reservation is
163 enforced at every door that creates a name without a temporary claim:
164
165 | Door | Refuses a `tmp-` name |
166 |---|---|
167 | `claimApp` (`server.go:3460`), so `/new`, the browser create form, and every other permanent claim | yes; `/new-temporary` is the only caller allowed through |
168 | rename destination (`appsettings.go`) | yes |
169 | `carlos add` (`cli.go:198`, box-local) | yes, for `--app` |
170 | `console grant <address> <app>` → `Store.Grant` (`cmd/console/main.go:124`, `store.go:522`) | yes **(astra 7.2)** |
171 | `BackfillOpsMemberships` (`server.go:1285`, `store.go:661`), which turns unclaimed grants into rows at start-up | skips a `tmp-` grant and logs it, never creates a permanent `tmp-` row **(astra 7.2)** |
172 | `carlos instances create` and deploy's host minting | already require an existing row, which for `tmp-` is temporary |
173
174 Direct-mode ship (ops only, no claim) can still write under a `tmp-`
175 prefix; the temporary claim then refuses that name because the prefix
176 is not empty, which is the safe direction.
177
178 **Rollout: enforcement first, then the scan, then the switch**
179 **(astra 7.1)**. Boxes on older binaries do not know the reservation,
180 and hand-added routes are visible only in each box's own registry. So
181 the create route ships **off**, behind a console setting
182 (`CARLOS_TEMPORARY_APPS=1` in the console unit's environment), and the
183 operator turns it on only after, in this order:
184
185 1. every box runs a platform binary that carries the reservation.
186 `carlos add` is box-local and runs the box's own installed binary,
187 so upgrading the box upgrades the writer; there is no second copy
188 of it anywhere else;
189 2. every box's registry, and the console's `apps` and `owners`
190 tables, show no `tmp-` name, and the result is recorded in the
191 rollout discussion.
192
193 After that nothing that can write a route or a row accepts a `tmp-`
194 name except `/new-temporary`. An operator deliberately running an
195 old binary kept in `/opt/carlos/versions` by hand is outside what any
196 platform guard can stop, and is not in scope. The scan is platform
197 work below the API, which AGENTS.md's operate-via-API rule allows; no
198 user ever runs it. The plan makes both steps release steps, not code.
199- **No routes for the name, on this console's registry** **(astra 5.1)**.
200 Kept as a second check behind the namespace, not as the proof. An app can exist with no
201 row and nothing in the bucket: a service added with `carlos add` over
202 a TCP upstream (`cli.go:198`) is only routes. `claimApp`'s apex check
203 lets a route of the same account and app through (`apex.go:92`), and
204 on expiry reconcile would drop every route of that account and app
205 (`reconcile.go:206`), the real service's included. So the temporary
206 route also refuses if the console's registry holds **any** route
207 whose account (`release.RouteAccount`) and app match, platform apex
208 included. It uses an account-scoped form of `appHasRoutes`
209 (`appsettings.go:711`, which matches on app name alone). A registry
210 that cannot be opened or listed refuses: "could not check" is not
211 "no routes".
212- The row is inserted with `expires_at` set, in the same `INSERT` that
213 claims the name (`CreateAppIn`, `store.go:1345`, which is
214 `ON CONFLICT DO NOTHING`). There is no moment where the row exists
215 permanent, and a racing permanent create of the same name gets
216 `claimTaken`.
217- The cap is checked under the console's account-level lock before the
218 insert: count rows in the account with `expires_at > 0` and
219 `deleted_at = 0`; refuse at 20. The refusal lists each with its expiry.
220 Because the count and the insert are serialised in the console, the
221 cap is exact **(astra 6)**.
222
223There is no door that sets `expires_at` on an existing row. `apps ttl`
224only moves a non-zero `expires_at`.
225
226**Rename refuses a temporary app** **(astra 2.1)**. The rename door
227(`appsettings.go:785`) checks that the *source* is unshipped, not that
228the *destination* prefix is empty, so a temporary row renamed onto a
229name with unclaimed releases would put those releases under expiry.
230Keep first, then rename.
231
232Direct mode (ship straight to the bucket, `ops` only) skips claims
233today and cannot create a temporary app. That is fine: the feature is
234API-mode only.
235
236## 4. Temporary apps stay static and platform-hosted (astra 7)
237
238Enforced at the **server** mutation paths, not in the CLI, on any row
239with `expires_at > 0`:
240
241| Path | Refuses |
242|---|---|
243| `handleAPIShip` (`apicli.go:1530`) | a manifest whose kind is not `static` |
244| `handleAPIInstanceCreate` (`apiinstances.go`) | kind other than `static`, and any host other than the platform host |
245| `handleAPIDomainAttach` and the console attach form (`domainattach.go`) | everything |
246| the object-store create door | everything |
247| the instances-manifest door, `private_storage: true` (`apiinstances.go:1751`) | everything **(astra 2.6)** |
248| the shared store-provisioning path instance create reaches (`apiinstances.go:995`) | everything, as a backstop to the row above |
249| email: `carlos email domains add` (`apimail.go:1016`), standalone credentials (`apimail.go:1391`), `email enable` | everything **(astra 2.2)** |
250| `SetProduction(…, true)` door | everything |
251| rename (`appsettings.go`) | everything (§3) |
252| move to another account, `MoveApp` (`appsettings.go:1350`, `store.go:1705`) | everything **(astra 3.2)** |
253| control-plane setter, `handleAppControlPlaneSet` → `SetAppControlPlane(…, true)` (`controlplane.go:288`) | everything **(astra 3.5)** |
254
255The rule behind the table: **a temporary row cannot change its name,
256its account, or its protection class.** Each of those doors either
257points the row at bucket state it did not create (rename, move) or
258takes it out of automatic destruction's reach (production, control
259plane). The refusals are enforced in the shared store operation where
260one exists (`RenameAppIn`, `MoveApp`, `SetAppControlPlane`), not only
261in the handlers, and each checks under the per-app mutex.
262
263Each refusal says `carlos apps keep` first. Keep lifts them all. The
264point of the store and email rows is that an automatic purge never
265meets backend estate it would need a person to tear down.
266Canary hosts for static apps (`reconcile.go:318-340`) still mint; they
267are platform hosts and go with the app.
268
269`carlos accounts migrate` opens the registry and bucket directly
270(`accounts.go:330`, `:346`) and never sees a console row, so it would
271copy a temporary app as permanent. It is an ops-only verb run by hand,
272and that outcome (the copy outlives the original) errs toward keeping
273data. Accepted **(astra 10)**.
274
275## 5. The expiry pass and the early purge
276
277A new console loop, `expirypass.go`, beside `economicspass.go` and
278`scanpass.go`, on a 5-minute ticker. Every transition below takes the
279per-app mutex that keep, ttl, restore, trash and the purge door also
280take, so a pass and a user command never interleave on one app
281**(astra 3, 4)**.
282
283**Settle sweep** **(astra 8.1)**. Each pass first compares every
284temporary row (`expires_at > 0`) with the one `apps/` listing it has
285already taken for the tripwire: a row whose trash state disagrees with
286the presence of its marker (live row with a marker, trashed row
287without one, or `restoring = 1`) gets `settleApp` under its mutex. This
288is what repairs a trash door that wrote the marker and then failed its
289row update (`apicli.go:1247`, `:1279`) while the expiry is still far
290off, without waiting for a console restart or a user touching the app.
291It costs no extra listing, and a GET only for rows that disagree. A
292tripped pass still runs the sweep's presence branch (a marker is
293evidence) and skips its absence branches, per §2.
294
295**Expire.** The pass lists rows with `0 < expires_at <= now` and
296`deleted_at = 0` as candidates. For each, under the mutex:
297
2981. `settleApp`, then re-read the row. Go on only if
299 `expires_at > 0 AND expires_at <= now AND deleted_at = 0 AND
300 restoring = 0` still holds **(astra 3.1)**: a keep that ran after the
301 listing set `expires_at = 0`, which fails `> 0`.
3022. Refuse, log and audit if the app is the control plane
303 (`controlPlaneRefusal`) **(astra 3.5)**. §4 makes this unreachable.
3043. `release.TrashApp(ctx, s, sqid, app, "expiry", now)`, then
305 `ReadAppTombstone` (create-only: if a marker already existed, its
306 clock stands, the same re-read `handleAPITrashApp` does).
3074. In one statement, guarded by `expires_at > 0 AND deleted_at = 0`:
308 `deleted_at = tomb.DeletedAt`, `purge_for = tomb.DeletedAt`,
309 `purge_after = tomb.DeletedAt + 3 days`.
310
311A failure at 3 writes nothing, and the next pass tries again. A failure
312at 4 leaves a marker and a live row. The mutex has not been released
313between 3 and 4, so no keep has run; the next door or pass to touch the
314app settles it (the marker wins, with the temporary window, per the
315materialize rule below). Keep and ttl settle first, so a keep after a
316failed step 4 finds the app in the Trash and is refused ("restore it
317first"), rather than succeeding on a row the bucket has already
318trashed. No in-progress flag is needed, which removes the round 3
319problem of one surviving a restore **(astra 3.3)**.
320
321**One deadline, everywhere** **(astra 4.2)**. A new
322`effectivePurgeAt(row)` returns `purge_after` when it is set and
323`deleted_at + TrashCooloff` otherwise. Every place that shows or
324returns a purge date uses it instead of computing
325`DeletedAt + TrashCooloff` itself:
326
327- the Trash view's `TrashedApps` / `PurgesAt` (`view.go:492`) and the
328 template's fixed "30 days" wording (`trash.html:10`, `:21`), which
329 becomes the per-app date;
330- the trash API's `restorable_until` (`apicli.go:1295`) and the CLI's
331 "restorable until" line (`apps.go:317`) in API mode;
332- the delete confirmation in the browser, and `/list` (§7).
333
334The box's own 30-day auto-purge is not a displayed deadline and is
335unchanged.
336
337**Direct mode** **(astra 7.4)**. `carlos apps delete` in direct mode
338(`apps.go:250`, `headlessTrash` at `:307`) has no console row, so it
339cannot know whether the app is temporary, and the `tmp-` prefix does not
340say (kept apps keep it). It stops promising a window it cannot know **(astra 8.2)**. The line
341is built from the marker's own `deleted_at`, which `headlessTrash`
342already re-reads (`apps.go:298`), so a retry never restarts the clock:
343"in the Trash since <T>. It is purged at <T+3d> if it is a temporary
344app, or <T+30d> if not; `carlos apps list` against the console shows
345which." A date already in the past is printed as "may be purged at any
346time" instead. Direct mode is ops-only.
347
348**Trash by hand.** `handleAPITrashApp` and the browser door set
349`purge_after`/`purge_for` the same way when the row has
350`expires_at > 0`, and leave them 0 otherwise.
351
352**Materialize** **(astra 2.5)**. Where `MaterializeApps` marks a row
353trashed from a marker (`materialize.go:367`), a row with
354`expires_at > 0` also gets `purge_for = tomb.DeletedAt` and
355`purge_after = tomb.DeletedAt + 3 days`, in the same statement. This is
356the recovery path when a trash door wrote the marker and failed the row
357update. The rule is the same one every door follows: the window is
358bound to the marker's clock and to whether the row was temporary when
359the marker was found.
360
361**Early purge.** For each row with `deleted_at > 0`, `purge_after > 0`,
362`purge_after <= now`: call a new
363
364 release.PurgeAppIfTrashedAt(ctx, s, pub, sqid, app, wantDeletedAt, now)
365
366which is `PurgeApp` plus one precondition: the current tombstone's
367`DeletedAt`, truncated to seconds, equals `wantDeletedAt` (the row's
368`purge_for`). A restore followed by a keep and a fresh trash writes a
369new tombstone with a new clock and a row with `purge_after = 0`, so the
370old decision can no longer fire **(astra 4)**. `PurgeReady`'s other
371guards (production flag, object-store estate) still apply. On success
372the row goes through the existing `PurgeAppRow`.
373
374Before calling it, the pass refuses, logs and audits (leaving the app
375for a person at the console purge door) if:
376
377- the app is the control plane (`controlPlaneRefusal`) **(astra 2.8)**;
378- it has any email estate, by the same test the purge door uses
379 (`mail.Get` plus `mailAppDomains`, `ownership.go:380-394`)
380 **(astra 2.2)**.
381
382§4 makes both impossible to reach; this is the backstop, and it never
383tears anything down on its own authority.
384
385Because the decision lives in the row and not in a bucket object,
386an interrupted `PurgeApp` leaves `purge_after` in place and the next
387pass resumes it **(astra 9)**.
388
389`ErrNotTrashed` from the early purge does **not** mean "restored"
390**(astra 2.4)**. `PurgeApp` deletes the marker last, so a finished purge
391whose `PurgeAppRow` failed, a 30-day box purge, a headless restore, and
392a §6 restore that crashed after deleting the marker all look the same
393at the marker. The pass tells them apart the way `MaterializeApps`
394already does (`materialize.go:376`): list the prefix.
395
396- **No objects, tripwire passes (§2):** the purge finished.
397 `PurgeAppRow` and stop. Tripwire fails: leave the row, log.
398- **Objects:** the app was restored outside the pass. Finish a restore
399 (§6 steps 2 and 5, no cap check since the bucket already serves it)
400 and log it. If the row has `restoring = 1` this is a console restore
401 that crashed or failed at step 5, and its cap slot was already held
402 (§6). If it has `restoring = 0` it was a direct-mode restore, whose
403 cap overrun §6 accepts.
404
405## 6. Restore (astra 6, 8)
406
407One helper, `s.restoreApp`, used by the CLI door
408(`handleAPIRestoreApp`), the browser door (`ownership.go:251`) and the
409§5 `ErrNotTrashed` catch-up. Under the per-app mutex, for a row with
410`expires_at > 0`:
411
4121. `settleApp`. Cap check, as §3, counting this app as a new live one.
413 Over the cap: refuse, nothing changes.
4142. If `expires_at < now + 1h`: `expires_at = now + 7 days`. The row is
415 still trashed, so this is harmless if what follows fails.
4163. `restoring = 1`. From here the app holds a cap slot **(astra 3.4)**.
4174. `release.RestoreApp` (delete the tombstone).
4185. One statement: `deleted_at = 0`, `restoring = 0`, `purge_after = 0`,
419 `purge_for = 0`.
420
421The cap counts rows with `expires_at > 0 AND (deleted_at = 0 OR
422restoring = 1)`, so a restore that has deleted the marker but not
423reached step 5 still occupies its slot, and no new temporary app can
424take it.
425
426`settleApp` resolves `restoring = 1` wherever it finds it:
427
428- **marker present:** step 4 failed. `restoring = 0`; the app stays in
429 the Trash with its deadline intact **(astra 2.3)**.
430- **marker absent, objects present:** step 5 failed or crashed. Do
431 step 5.
432- **marker absent, no objects:** a box purge got there; `PurgeAppRow`,
433 subject to the §2 tripwire.
434
435Step 2 before 4 means the expiry pass never sees an untrashed row with
436a stale expiry. Headless `carlos apps restore` in direct mode
437writes only the bucket; the row catches up through the `ErrNotTrashed`
438path or the start-up materialize, and at that point gets step 2. It
439does not get the cap check. Direct mode is ops-only, and that overrun is
440accepted.
441
442## 7. Commands
443
444All user-facing strings go through copy review before they are written
445into code.
446
447| Command | Does |
448|---|---|
449| `carlos deploy --temporary [--ttl 3d] ./dir` | Creates the app through `/new-temporary` (§3), then ships and promotes as today. `--ttl` alone implies `--temporary`. Default 7d, max 30d, min 1h. Without `--app`, the name is `tmp-` plus 6 random lowercase letters and digits, retried on `claimTaken`. With `--app acme-docs` the name is `tmp-acme-docs` (an `--app` already starting `tmp-` is used as given), and the CLI prints the final name before shipping. Refused locally with `--host` or `--kind binary`. |
450| `carlos deploy` to an existing temporary app | Ships and promotes. Expiry unchanged, printed. |
451| `carlos deploy --temporary` to an existing app | Refused: the create door says the name is taken. |
452| `carlos apps ttl --app <a> 5d` | `expires_at = now + 5d`, same bounds. Live temporary apps only. |
453| `carlos apps keep --app <a>` | `expires_at = 0`. Live apps only; settles first, so an app the bucket has already trashed is refused. |
454| `carlos apps list [--account <a>]` | **New subcommand** **(astra 3.6)**. One line per app the caller can see, trashed ones included: name, `live` or `trashed`, and `expires in 3d`, `purged in 2d` or nothing. |
455| `carlos apps delete` on a temporary app | As today; the output says "purged in 3 days". |
456
457API additions (`authcli.go`, `svcrole.Publish` like `/new`):
458
459 POST /api/cli/apps/{account}/new-temporary {"name": "...", "ttl_seconds": N}
460 POST /api/cli/apps/{account}/{app}/ttl {"ttl_seconds": N}
461 POST /api/cli/apps/{account}/{app}/keep
462 GET /api/cli/apps/{account}/list
463
464`/list` is new rather than a change to the accounts listing, whose
465`apps` field is a `[]string` that clients decode as such
466(`apicli.go:548`, `account_choose.go:27`) and whose query hides trashed
467apps (`store.go:1362`). `/list` returns `[{"app", "deleted_at",
468"expires_at", "purge_after"}]` with times as RFC 3339 or null, includes
469trashed apps, and applies the same per-principal filtering the accounts
470listing applies to service principals (`apicli.go` just above 548). `docs/site/cli.md` gains the flags and both commands.
471
472The console app page shows the expiry in the header with a Keep button
473(deploy rights; no passkey step, since keep only makes the app safer).
474
475## 8. What this does not do
476
477- No email warning before expiry. Tenant email senders are not merged.
478 (Email *provisioning* exists and is refused on temporary apps, §4.)
479- No byte cap across temporary apps. The storage pass could feed one.
480- No temporary binary apps or instances.
481- No change to how edge boxes clean their local static cache; that gap
482 applies to every app and gets its own discussion.
483
484## 9. Testing
485
486- Store: migration adds the four columns with defaults; `CreateAppIn`
487 with an expiry; conditional updates return zero rows when they lose.
488- Namespace: `/new`, the browser form, rename and `carlos add` refuse
489 `tmp-` names; `/new-temporary` refuses names without it.
490- Create route: refuses a name with a route of the same account and
491 app and no bucket objects or row (a `carlos add` service); refuses
492 when the registry cannot be read; refuses a name with any key under
493 its prefix, including
494 a manifest with no channel pointer; owner role does not exempt it;
495 cap refuses the 21st and is exact under concurrent creates; the CLI
496 against a console without the route creates nothing.
497- Server paths in §4 each refuse on a temporary row and pass after keep.
498- Expiry pass, fake clock and a recording store: expire → trash →
499 purge at 3 days; keep between candidate listing and lock acquisition
500 (the pass must skip); failure at step 3 retries; failure at step 4
501 followed by a keep (keep refused, row settled trashed with the 3-day
502 window); restore→keep→trash between decision
503 and purge refuses via `PurgeAppIfTrashedAt`; interrupted purge resumes;
504 `ErrNotTrashed` with an empty prefix finalises, with objects restores;
505 control-plane and email-estate backstops refuse without tearing down.
506- Materialize: a marker found for a temporary row sets the 3-day window
507 from the marker's clock; for a permanent row, leaves it 0.
508- Restore: rewrites expiry before deleting the tombstone and clears the
509 deadline only after (order asserted); a failed tombstone delete keeps
510 the deadline and releases `restoring`; a failed step 5 holds the cap
511 slot until settled; cap enforced on every console restore door.
512- `settleApp`: each marker/row/`restoring` combination.
513- Empty bucket: start the console against an empty replacement bucket
514 with one temporary row awaiting purge and one live but expired; run
515 startup materialize and then several expiry passes; no marker is
516 written, and both rows survive unchanged. The same with a bucket that
517 holds only trash markers.
518- Deadline display: a temporary and a permanent app trashed together;
519 the Trash view, trash API, CLI output and `/list` each show 3 and 30
520 days respectively; direct-mode delete prints both dates from the
521 marker, including on a retry two days in and after the 3-day date.
522- Settle sweep: manual trash of a temporary app with 30 days left whose
523 row update fails; with no restart and no user action, the next pass
524 settles it and the early purge runs at 3 days.
525- Early purge whose final marker delete fails, with no other keys in
526 the bucket: the next pass trips the tripwire, still retries the
527 purge, finishes, and removes the row.
528- `console grant` refuses a `tmp-` name; backfill skips one already in
529 `owners`; `carlos add --app tmp-x` refuses.
530- The create route answers 404 until `CARLOS_TEMPORARY_APPS=1`.
531- `/list`: trashed apps shown; the accounts listing's `[]string`
532 unchanged; service-principal filtering matches.
533- Rename, move, control-plane, email, private storage and store doors
534 refuse on a temporary row, at the store operation as well as the
535 handler.
536- Boxes: no change; an existing reconcile test confirms a trashed app
537 with no row knowledge still purges at 30 days.
538- CLI: `--ttl` implies `--temporary`; local refusals; generated name
539 shape and retry.
540
541## 10. Round 1 dispositions
542
543| # | Finding | Disposition |
544|---|---|---|
545| 1 | Expiry signature validates as production-off | Fixed by §2: no signed bucket record |
546| 2 | Five checks miss shipped-unpromoted and legacy state | Fixed by §3: `claimApp` with `ownerClaim=false`, any key refuses |
547| 3 | Keep/ttl race the expiry | Fixed by §5: per-app mutex, conditional updates (rev 4 corrects the condition, astra 3.1) |
548| 4 | Purge re-read misses deadline | Fixed by §5: `PurgeAppIfTrashedAt` binds to `purge_for` |
549| 5 | Deleting expiry does not revoke it | Fixed by §2: no bucket record to replay |
550| 6 | Restore bypasses cap | Fixed by §6 for console doors; direct-mode restore accepted |
551| 7 | Static-only enforced only in CLI | Fixed by §4: server paths |
552| 8 | Browser and headless restore miss expiry | Fixed by §6: shared helper; headless catches up via §5 |
553| 9 | Interrupted purge falls back to 30 days | Fixed by §5: decision lives in the row |
554| 10 | Migration disables expiry | Fixed by §4: migrate refuses in API mode; direct mode accepted |
555
556## 11. Round 2 dispositions
557
558| # | Finding | Disposition |
559|---|---|---|
560| R1.4 | Box auto-purge checks an old tombstone's age, then purges unguarded | Declined here, filed separately. This race exists for every app today and this spec leaves the box path unchanged: a box only acts on a tombstone 30 days old, temporary or not. Guarding it (`PurgeAppIfTrashedAt` with the listed marker's clock) is a small, separate box change, filed as amadan discussion #182. |
561| R1.10 | Migrate has no API mode | Fixed: §4 now describes migrate as it is and accepts the outcome. |
562| 2.1 | Rename puts permanent data under expiry | Fixed: §3 and §4, rename refuses a temporary app |
563| 2.2 | Early purge skips mail teardown | Fixed: §4 refuses email on temporary apps; §5 backstop refuses to purge with email estate |
564| 2.3 | Failed restore cancels the 3-day window | Fixed: §6 clears the deadline only with the row restore |
565| 2.4 | `ErrNotTrashed` is not proof of restore | Fixed: §5 lists the prefix, as materialize does |
566| 2.5 | Materialize omits the window | Fixed: §5 materialize rule |
567| 2.6 | Instance manifest enables private storage | Fixed: §4 rows for the manifest door and the shared provisioning path |
568| 2.7 | Older console makes `--temporary` permanent | Fixed: §3 separate route; CLI checks `expires_at` |
569| 2.8 | Expiry bypasses control-plane protection | Fixed: §3 route takes no `control_plane`; §5 backstop |
570
571## 12. Round 3 dispositions
572
573| # | Finding | Disposition |
574|---|---|---|
575| 2.7 residue | §7 still said `/new` | Fixed: §7 names `/new-temporary` |
576| 3.1 | Expiry update matches a kept row (`0 <= now`) | Fixed: §5 re-checks `expires_at > 0` under the mutex after settling |
577| 3.2 | Account move puts a permanent site under expiry | Fixed: §4 move refuses, at `MoveApp` |
578| 3.3 | `expiring` survives restore | Fixed: `expiring` removed; §2 settle-first rule covers the crash it guarded |
579| 3.4 | Failed restore frees a cap slot | Fixed: §6 `restoring` holds the slot until settled |
580| 3.5 | Control-plane setter on a temporary app | Fixed: §4 refuses; §5 re-checks before trash as well as purge |
581| 3.6 | `apps list` and the listing shape do not exist | Fixed: §7 new subcommand and `/list` endpoint; old contract unchanged |
582
583## 13. Round 4 dispositions
584
585| # | Finding | Disposition |
586|---|---|---|
587| 4.1 | Absence-based recovery skips the empty-bucket tripwire | Fixed: §2 absence rule, shared tripwire helper; §5 and §6 branches cite it |
588| 4.2 | Trash page and API promise 30 days | Fixed: §5 `effectivePurgeAt` used by every displayed deadline |
589
590## 14. Round 5 dispositions
591
592| # | Finding | Disposition |
593|---|---|---|
594| 5.1 | Route-only permanent service can be claimed temporary | Fixed: §3 refuses when any route of the account and app exists, or the registry cannot be read |
595| 5.2 | Expiry's own markers defeat the empty-bucket tripwire | Fixed: §2 a tripped pass writes nothing; the tripwire ignores trash markers |
596
597## 15. Round 6 dispositions
598
599| # | Finding | Disposition |
600|---|---|---|
601| 6.1 | Console registry misses routes on other boxes | Fixed by ruling 7: reserved `tmp-` namespace (§3); the registry check stays as a second line |
602| 6.2 | Reconcile can re-stamp a same-name route to the temporary app's account | Fixed by ruling 7: no permanent route can carry a `tmp-` name |
603| 5.1 residue | Route check incomplete | Superseded by ruling 7 |
604
605## 16. Round 7 dispositions
606
607| # | Finding | Disposition |
608|---|---|---|
609| 7.1 | Rollout scan gives no lasting protection against older writers | Fixed: §3 ships the route off; enable only after every box carries the reservation and the scan is clean |
610| 7.2 | Grant and backfill bypass the namespace | Fixed: §3 table, `Store.Grant` refuses, backfill skips |
611| 7.3 | Interrupted last-app purge cannot retry under the tripwire | Fixed: §2 early purge acts on marker presence, which is evidence, and still runs when tripped |
612| 7.4 | Direct-mode delete promises 30 days | Fixed: §5 direct-mode line no longer promises 30 days |
613
614## 17. Round 8 dispositions
615
616| # | Finding | Disposition |
617|---|---|---|
618| 8.1 | Failed manual-trash row update escapes both candidate queries | Fixed: §5 settle sweep over every temporary row against the pass's listing |
619| 8.2 | Direct-mode retry overpromises | Fixed: §5 dates from the marker's `deleted_at`, elapsed dates said as such |
620