# Temporary static sites

Status: 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.
Revs 2-9 answer Astra rounds 1-8
(`CODEX-FINDINGS-TEMPORARY-SITES-R{1..8}.md`). §§10-17 disposition
each finding; the text marks fixes **(astra N)**, **(astra 2.N)** and so
on.

## 0. Why

Two uses want a site that cleans itself up:

- an LLM scratchpad: an agent deploys a directory, looks at it, iterates,
  and walks away;
- a one-shot page for a customer: documentation, a proposal, a report
  that matters for a week.

Today both become permanent apps. Nothing expires them, there is no
storage quota (only the 256 MiB per-ship cap, `apiShipMaxBytes`), and an
agent in a loop can leave hundreds of apps behind. The opposite risk is
as real: a real site marked temporary by mistake must never quietly
vanish.

## 1. Rulings (fixed)

1. **The unit is the whole app.** Not a host on an existing app, not a
   release.
2. **Expiry trashes; purge follows after a short window.** At expiry the
   app goes to the Trash exactly as `carlos apps delete` would put it
   there (routes drop, `apps restore` works). A temporary app is purged
   3 days after it was trashed, not 30.
3. **Bounded while live.** TTL defaults to 7 days, maximum 30. An account
   holds at most 20 live temporary apps; past that a new one is refused,
   never evicted.
4. **`carlos apps keep`** makes a temporary app permanent; **`carlos apps
   ttl`** moves its expiry.
5. **Temporary apps get the platform host only.** No custom domains.
6. **Only a brand-new app can be made temporary.**
7. **Temporary apps live in a reserved `tmp-` namespace** (Paul,
   2026-09-25, after Astra round 6). Every temporary app is named
   `tmp-<label>`. Nothing permanent may be *created* under a `tmp-` name.
   A kept app keeps its `tmp-` name.
8. **Temporary creates have their own budget of 50 per principal per
   day** (Paul, 2026-09-26, raised while planning). It is separate from
   the permanent create budget of 10 a day (`authcli.go:119`), which
   would refuse a scratchpad agent at its 11th app. The live cap (20)
   bounds storage while apps are live. This budget bounds churn: a
   trashed temporary app holds storage for its 3-day window, so the
   worst case is about 150 trashed-but-unpurged apps per principal.

## 2. Where the state lives: the console, not the bucket

Rev 1 put a signed `expiry.json` in the bucket and had boxes act on it.
Every P1 in round 1 traced back to that: a signed record that authorises
destruction can be confused with another signed record (astra 1),
replayed after it is revoked (astra 5), copied by migration (astra 10),
deleted mid-purge (astra 9), and raced by writers that share no lock
(astra 3, 4).

Rev 2 keeps the lifecycle **in the console's `apps` table** and makes the
console the only actor that expires or early-purges anything.

- **Boxes learn nothing new.** They see a trash marker, drop routes, and
  their existing auto-purge still runs at 30 days
  (`reconcile.go:226-238`). For a temporary app that pass is a backstop
  the console normally beats by 27 days. No box code changes.
- **Nothing in the bucket can make an app temporary**, so bucket write
  access cannot shorten a permanent app's purge window, and there is no
  record to forge, replay or copy.
- **All transitions serialise in one place**: a per-app mutex in the
  console process plus conditional `UPDATE`s on the row (§5).
- **Every transition settles first** **(astra 3.1, 3.3)**. Under the
  mutex, before acting, each door and pass calls `s.settleApp`, which
  reads the app's tombstone and brings the row into line with it: the
  one-app form of `MaterializeApps`, extended by §5 and §6. A crash
  between a bucket write and its row write is therefore repaired by the
  next thing that touches the app, not only at console start
  (`materializeQuietly` runs once, `server.go:1278`). After settling,
  the door re-reads the row and re-checks its own preconditions; a
  candidate list taken before the lock is only a hint.
- **Absence is never evidence on its own** **(astra 4.1)**. Every branch
  below that reasons from a missing marker and missing objects to "the
  purge finished" and calls `PurgeAppRow` first requires the
  empty-bucket tripwire `MaterializeApps` already has
  (`materialize.go:296`, `:388`): a successful listing of `apps/` that
  is **not empty**. An empty or failed listing means "no evidence"; the
  row is left exactly as it is and the pass logs the same warning
  materialize does. The tripwire is factored out of `MaterializeApps`
  into one helper that both use. The expiry pass runs it once per pass;
  a door runs it only if it reaches an absence branch.
- **A tripped pass writes nothing it cannot justify** **(astra 5.2,
  7.3)**. If the tripwire fails at the start of an expiry pass, the pass
  does no expiry trashes and no absence finalisation. It **does** still
  retry early purges, because `PurgeAppIfTrashedAt` acts only on the
  *presence* of a marker whose `deleted_at` matches the row's
  `purge_for` to the second: that match is evidence of the right
  bucket, and a wrong, empty bucket has no such marker (it gets
  `ErrNotTrashed`, which is an absence branch and waits). This matters
  when an early purge of a deployment's last app fails on its final
  marker delete: the marker is then the only key left, the tripwire
  trips on every pass, and without this exception nothing would retry
  it. When `PurgeAppIfTrashedAt` succeeds, the pass calls `PurgeAppRow`
  in the same step, so a successful purge never needs the absence
  branch to finish. Otherwise the
  pass's own trash markers would be the only thing in a wrong, empty
  bucket, and the next pass would read them as evidence the bucket is
  real. For the same reason the tripwire counts only keys that are
  **not** trash markers (`deleted.json`): markers are the one thing
  this feature writes into a bucket that holds nothing else. What is
  left is the corner `MaterializeApps` already accepts in its comment
  (`materialize.go:311` onward): other writers, like a user's deploy,
  landing in the wrong bucket. This spec does not widen it.

This assumes one console per deployment bucket, which is how every
deployment runs today. If the console is down, nothing expires and
nothing early-purges; both fail toward keeping data.

New columns on `apps` (migration in `store.go`):

| Column | Meaning |
|---|---|
| `expires_at INTEGER NOT NULL DEFAULT 0` | 0 = permanent. Unix seconds otherwise. |
| `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. |
| `purge_after INTEGER NOT NULL DEFAULT 0` | 0 = no early purge. Set only when a temporary app is trashed. |
| `purge_for INTEGER NOT NULL DEFAULT 0` | The tombstone `deleted_at` (Unix seconds) that `purge_after` was computed from. |

`purge_after` and `purge_for` are fixed **at trash time**, from whether
the app was temporary at that moment. Nothing later re-derives them.

## 3. Creation: a fresh claim, never an adoption

Temporary apps are created through a **new route**,
`POST /api/cli/apps/{account}/new-temporary` `{"name": "...",
"ttl_seconds": N}`, that shares `handleAPICreateApp`'s body
(`apicli.go:359` onward) but not its request type. A separate route, not
a field on `/new`, because `/new`'s decoder accepts unknown fields
(`authcli.go:940`): an older console would take `temporary` silently
and create a permanent app. An older console answers the new route with
404, and the CLI stops there with nothing created **(astra 2.7)**. The
CLI also checks the response carries a non-zero `expires_at` before it
ships.

The route takes no `control_plane`, `place` or `build_probe`; a
temporary app is never the control plane **(astra 2.8)**. On this route
**(astra 2)**:

- `claimApp` runs with `ownerClaim = false` regardless of role. An app
  name with **any** object under its prefix (and, for ops, the legacy
  prefix) is refused (`server.go:3487` onward). Shipped-but-unpromoted
  releases count, because the check is "any key", not "any pointer".
  Together with the namespace and route checks below, this is the brand-new test: `claimApp` already checks the row,
  the apex hold and the prefixes.
- **The name is in the `tmp-` namespace** (ruling 7) **(astra 6.1,
  6.2)**. This is what makes "brand new" provable. Routes are tied to
  apps by name alone, the console cannot see hand-added routes on other
  boxes (`apex.go:74`), and reconcile can re-stamp a route with
  whichever account solely owns the name in the bucket
  (`reconcile.go:723-735`, before the trash drop at `:189`/`:206`). No
  inventory the console can read proves a name unused across the fleet.
  A reserved namespace makes the question unnecessary: no permanent
  app, row or route can hold a `tmp-` name, so a temporary app's expiry
  can only ever drop routes a temporary app created. The reservation is
  enforced at every door that creates a name without a temporary claim:

  | Door | Refuses a `tmp-` name |
  |---|---|
  | `claimApp` (`server.go:3460`), so `/new`, the browser create form, and every other permanent claim | yes; `/new-temporary` is the only caller allowed through |
  | rename destination (`appsettings.go`) | yes |
  | `carlos add` (`cli.go:198`, box-local) | yes, for `--app` |
  | `console grant <address> <app>` → `Store.Grant` (`cmd/console/main.go:124`, `store.go:522`) | yes **(astra 7.2)** |
  | `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)** |
  | `carlos instances create` and deploy's host minting | already require an existing row, which for `tmp-` is temporary |

  Direct-mode ship (ops only, no claim) can still write under a `tmp-`
  prefix; the temporary claim then refuses that name because the prefix
  is not empty, which is the safe direction.

  **Rollout: enforcement first, then the scan, then the switch**
  **(astra 7.1)**. Boxes on older binaries do not know the reservation,
  and hand-added routes are visible only in each box's own registry. So
  the create route ships **off**, behind a console setting
  (`CARLOS_TEMPORARY_APPS=1` in the console unit's environment), and the
  operator turns it on only after, in this order:

  1. every box runs a platform binary that carries the reservation.
     `carlos add` is box-local and runs the box's own installed binary,
     so upgrading the box upgrades the writer; there is no second copy
     of it anywhere else;
  2. every box's registry, and the console's `apps` and `owners`
     tables, show no `tmp-` name, and the result is recorded in the
     rollout discussion.

  After that nothing that can write a route or a row accepts a `tmp-`
  name except `/new-temporary`. An operator deliberately running an
  old binary kept in `/opt/carlos/versions` by hand is outside what any
  platform guard can stop, and is not in scope. The scan is platform
  work below the API, which AGENTS.md's operate-via-API rule allows; no
  user ever runs it. The plan makes both steps release steps, not code.
- **No routes for the name, on this console's registry** **(astra 5.1)**.
  Kept as a second check behind the namespace, not as the proof. An app can exist with no
  row and nothing in the bucket: a service added with `carlos add` over
  a TCP upstream (`cli.go:198`) is only routes. `claimApp`'s apex check
  lets a route of the same account and app through (`apex.go:92`), and
  on expiry reconcile would drop every route of that account and app
  (`reconcile.go:206`), the real service's included. So the temporary
  route also refuses if the console's registry holds **any** route
  whose account (`release.RouteAccount`) and app match, platform apex
  included. It uses an account-scoped form of `appHasRoutes`
  (`appsettings.go:711`, which matches on app name alone). A registry
  that cannot be opened or listed refuses: "could not check" is not
  "no routes".
- The row is inserted with `expires_at` set, in the same `INSERT` that
  claims the name (`CreateAppIn`, `store.go:1345`, which is
  `ON CONFLICT DO NOTHING`). There is no moment where the row exists
  permanent, and a racing permanent create of the same name gets
  `claimTaken`.
- The cap is checked under the console's account-level lock before the
  insert: count rows in the account with `expires_at > 0` and
  `deleted_at = 0`; refuse at 20. The refusal lists each with its expiry.
  Because the count and the insert are serialised in the console, the
  cap is exact **(astra 6)**.

There is no door that sets `expires_at` on an existing row. `apps ttl`
only moves a non-zero `expires_at`.

**Rename refuses a temporary app** **(astra 2.1)**. The rename door
(`appsettings.go:785`) checks that the *source* is unshipped, not that
the *destination* prefix is empty, so a temporary row renamed onto a
name with unclaimed releases would put those releases under expiry.
Keep first, then rename.

Direct mode (ship straight to the bucket, `ops` only) skips claims
today and cannot create a temporary app. That is fine: the feature is
API-mode only.

## 4. Temporary apps stay static and platform-hosted (astra 7)

Enforced at the **server** mutation paths, not in the CLI, on any row
with `expires_at > 0`:

| Path | Refuses |
|---|---|
| `handleAPIShip` (`apicli.go:1530`) | a manifest whose kind is not `static` |
| `handleAPIInstanceCreate` (`apiinstances.go`) | kind other than `static`, and any host other than the platform host |
| `handleAPIDomainAttach` and the console attach form (`domainattach.go`) | everything |
| the object-store create door | everything |
| the instances-manifest door, `private_storage: true` (`apiinstances.go:1751`) | everything **(astra 2.6)** |
| the shared store-provisioning path instance create reaches (`apiinstances.go:995`) | everything, as a backstop to the row above |
| email: `carlos email domains add` (`apimail.go:1016`), standalone credentials (`apimail.go:1391`), `email enable` | everything **(astra 2.2)** |
| `SetProduction(…, true)` door | everything |
| rename (`appsettings.go`) | everything (§3) |
| move to another account, `MoveApp` (`appsettings.go:1350`, `store.go:1705`) | everything **(astra 3.2)** |
| control-plane setter, `handleAppControlPlaneSet` → `SetAppControlPlane(…, true)` (`controlplane.go:288`) | everything **(astra 3.5)** |

The rule behind the table: **a temporary row cannot change its name,
its account, or its protection class.** Each of those doors either
points the row at bucket state it did not create (rename, move) or
takes it out of automatic destruction's reach (production, control
plane). The refusals are enforced in the shared store operation where
one exists (`RenameAppIn`, `MoveApp`, `SetAppControlPlane`), not only
in the handlers, and each checks under the per-app mutex.

Each refusal says `carlos apps keep` first. Keep lifts them all. The
point of the store and email rows is that an automatic purge never
meets backend estate it would need a person to tear down.
Canary hosts for static apps (`reconcile.go:318-340`) still mint; they
are platform hosts and go with the app.

`carlos accounts migrate` opens the registry and bucket directly
(`accounts.go:330`, `:346`) and never sees a console row, so it would
copy a temporary app as permanent. It is an ops-only verb run by hand,
and that outcome (the copy outlives the original) errs toward keeping
data. Accepted **(astra 10)**.

## 5. The expiry pass and the early purge

A new console loop, `expirypass.go`, beside `economicspass.go` and
`scanpass.go`, on a 5-minute ticker. Every transition below takes the
per-app mutex that keep, ttl, restore, trash and the purge door also
take, so a pass and a user command never interleave on one app
**(astra 3, 4)**.

**Settle sweep** **(astra 8.1)**. Each pass first compares every
temporary row (`expires_at > 0`) with the one `apps/` listing it has
already taken for the tripwire: a row whose trash state disagrees with
the presence of its marker (live row with a marker, trashed row
without one, or `restoring = 1`) gets `settleApp` under its mutex. This
is what repairs a trash door that wrote the marker and then failed its
row update (`apicli.go:1247`, `:1279`) while the expiry is still far
off, without waiting for a console restart or a user touching the app.
It costs no extra listing, and a GET only for rows that disagree. A
tripped pass still runs the sweep's presence branch (a marker is
evidence) and skips its absence branches, per §2.

**Expire.** The pass lists rows with `0 < expires_at <= now` and
`deleted_at = 0` as candidates. For each, under the mutex:

1. `settleApp`, then re-read the row. Go on only if
   `expires_at > 0 AND expires_at <= now AND deleted_at = 0 AND
   restoring = 0` still holds **(astra 3.1)**: a keep that ran after the
   listing set `expires_at = 0`, which fails `> 0`.
2. Refuse, log and audit if the app is the control plane
   (`controlPlaneRefusal`) **(astra 3.5)**. §4 makes this unreachable.
3. `release.TrashApp(ctx, s, sqid, app, "expiry", now)`, then
   `ReadAppTombstone` (create-only: if a marker already existed, its
   clock stands, the same re-read `handleAPITrashApp` does).
4. In one statement, guarded by `expires_at > 0 AND deleted_at = 0`:
   `deleted_at = tomb.DeletedAt`, `purge_for = tomb.DeletedAt`,
   `purge_after = tomb.DeletedAt + 3 days`.

A failure at 3 writes nothing, and the next pass tries again. A failure
at 4 leaves a marker and a live row. The mutex has not been released
between 3 and 4, so no keep has run; the next door or pass to touch the
app settles it (the marker wins, with the temporary window, per the
materialize rule below). Keep and ttl settle first, so a keep after a
failed step 4 finds the app in the Trash and is refused ("restore it
first"), rather than succeeding on a row the bucket has already
trashed. No in-progress flag is needed, which removes the round 3
problem of one surviving a restore **(astra 3.3)**.

**One deadline, everywhere** **(astra 4.2)**. A new
`effectivePurgeAt(row)` returns `purge_after` when it is set and
`deleted_at + TrashCooloff` otherwise. Every place that shows or
returns a purge date uses it instead of computing
`DeletedAt + TrashCooloff` itself:

- the Trash view's `TrashedApps` / `PurgesAt` (`view.go:492`) and the
  template's fixed "30 days" wording (`trash.html:10`, `:21`), which
  becomes the per-app date;
- the trash API's `restorable_until` (`apicli.go:1295`) and the CLI's
  "restorable until" line (`apps.go:317`) in API mode;
- the delete confirmation in the browser, and `/list` (§7).

The box's own 30-day auto-purge is not a displayed deadline and is
unchanged.

**Direct mode** **(astra 7.4)**. `carlos apps delete` in direct mode
(`apps.go:250`, `headlessTrash` at `:307`) has no console row, so it
cannot know whether the app is temporary, and the `tmp-` prefix does not
say (kept apps keep it). It stops promising a window it cannot know **(astra 8.2)**. The line
is built from the marker's own `deleted_at`, which `headlessTrash`
already re-reads (`apps.go:298`), so a retry never restarts the clock:
"in the Trash since <T>. It is purged at <T+3d> if it is a temporary
app, or <T+30d> if not; `carlos apps list` against the console shows
which." A date already in the past is printed as "may be purged at any
time" instead. Direct mode is ops-only.

**Trash by hand.** `handleAPITrashApp` and the browser door set
`purge_after`/`purge_for` the same way when the row has
`expires_at > 0`, and leave them 0 otherwise.

**Materialize** **(astra 2.5)**. Where `MaterializeApps` marks a row
trashed from a marker (`materialize.go:367`), a row with
`expires_at > 0` also gets `purge_for = tomb.DeletedAt` and
`purge_after = tomb.DeletedAt + 3 days`, in the same statement. This is
the recovery path when a trash door wrote the marker and failed the row
update. The rule is the same one every door follows: the window is
bound to the marker's clock and to whether the row was temporary when
the marker was found.

**Early purge.** For each row with `deleted_at > 0`, `purge_after > 0`,
`purge_after <= now`: call a new

    release.PurgeAppIfTrashedAt(ctx, s, pub, sqid, app, wantDeletedAt, now)

which is `PurgeApp` plus one precondition: the current tombstone's
`DeletedAt`, truncated to seconds, equals `wantDeletedAt` (the row's
`purge_for`). A restore followed by a keep and a fresh trash writes a
new tombstone with a new clock and a row with `purge_after = 0`, so the
old decision can no longer fire **(astra 4)**. `PurgeReady`'s other
guards (production flag, object-store estate) still apply. On success
the row goes through the existing `PurgeAppRow`.

Before calling it, the pass refuses, logs and audits (leaving the app
for a person at the console purge door) if:

- the app is the control plane (`controlPlaneRefusal`) **(astra 2.8)**;
- it has any email estate, by the same test the purge door uses
  (`mail.Get` plus `mailAppDomains`, `ownership.go:380-394`)
  **(astra 2.2)**.

§4 makes both impossible to reach; this is the backstop, and it never
tears anything down on its own authority.

Because the decision lives in the row and not in a bucket object,
an interrupted `PurgeApp` leaves `purge_after` in place and the next
pass resumes it **(astra 9)**.

`ErrNotTrashed` from the early purge does **not** mean "restored"
**(astra 2.4)**. `PurgeApp` deletes the marker last, so a finished purge
whose `PurgeAppRow` failed, a 30-day box purge, a headless restore, and
a §6 restore that crashed after deleting the marker all look the same
at the marker. The pass tells them apart the way `MaterializeApps`
already does (`materialize.go:376`): list the prefix.

- **No objects, tripwire passes (§2):** the purge finished.
  `PurgeAppRow` and stop. Tripwire fails: leave the row, log.
- **Objects:** the app was restored outside the pass. Finish a restore
  (§6 steps 2 and 5, no cap check since the bucket already serves it)
  and log it. If the row has `restoring = 1` this is a console restore
  that crashed or failed at step 5, and its cap slot was already held
  (§6). If it has `restoring = 0` it was a direct-mode restore, whose
  cap overrun §6 accepts.

## 6. Restore (astra 6, 8)

One helper, `s.restoreApp`, used by the CLI door
(`handleAPIRestoreApp`), the browser door (`ownership.go:251`) and the
§5 `ErrNotTrashed` catch-up. Under the per-app mutex, for a row with
`expires_at > 0`:

1. `settleApp`. Cap check, as §3, counting this app as a new live one.
   Over the cap: refuse, nothing changes.
2. If `expires_at < now + 1h`: `expires_at = now + 7 days`. The row is
   still trashed, so this is harmless if what follows fails.
3. `restoring = 1`. From here the app holds a cap slot **(astra 3.4)**.
4. `release.RestoreApp` (delete the tombstone).
5. One statement: `deleted_at = 0`, `restoring = 0`, `purge_after = 0`,
   `purge_for = 0`.

The cap counts rows with `expires_at > 0 AND (deleted_at = 0 OR
restoring = 1)`, so a restore that has deleted the marker but not
reached step 5 still occupies its slot, and no new temporary app can
take it.

`settleApp` resolves `restoring = 1` wherever it finds it:

- **marker present:** step 4 failed. `restoring = 0`; the app stays in
  the Trash with its deadline intact **(astra 2.3)**.
- **marker absent, objects present:** step 5 failed or crashed. Do
  step 5.
- **marker absent, no objects:** a box purge got there; `PurgeAppRow`,
  subject to the §2 tripwire.

Step 2 before 4 means the expiry pass never sees an untrashed row with
a stale expiry. Headless `carlos apps restore` in direct mode
writes only the bucket; the row catches up through the `ErrNotTrashed`
path or the start-up materialize, and at that point gets step 2. It
does not get the cap check. Direct mode is ops-only, and that overrun is
accepted.

## 7. Commands

All user-facing strings go through copy review before they are written
into code.

| Command | Does |
|---|---|
| `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`. |
| `carlos deploy` to an existing temporary app | Ships and promotes. Expiry unchanged, printed. |
| `carlos deploy --temporary` to an existing app | Refused: the create door says the name is taken. |
| `carlos apps ttl --app <a> 5d` | `expires_at = now + 5d`, same bounds. Live temporary apps only. |
| `carlos apps keep --app <a>` | `expires_at = 0`. Live apps only; settles first, so an app the bucket has already trashed is refused. |
| `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. |
| `carlos apps delete` on a temporary app | As today; the output says "purged in 3 days". |

API additions (`authcli.go`, `svcrole.Publish` like `/new`):

    POST /api/cli/apps/{account}/new-temporary {"name": "...", "ttl_seconds": N}
    POST /api/cli/apps/{account}/{app}/ttl     {"ttl_seconds": N}
    POST /api/cli/apps/{account}/{app}/keep
    GET  /api/cli/apps/{account}/list

`/list` is new rather than a change to the accounts listing, whose
`apps` field is a `[]string` that clients decode as such
(`apicli.go:548`, `account_choose.go:27`) and whose query hides trashed
apps (`store.go:1362`). `/list` returns `[{"app", "deleted_at",
"expires_at", "purge_after"}]` with times as RFC 3339 or null, includes
trashed apps, and applies the same per-principal filtering the accounts
listing applies to service principals (`apicli.go` just above 548). `docs/site/cli.md` gains the flags and both commands.

The console app page shows the expiry in the header with a Keep button
(deploy rights; no passkey step, since keep only makes the app safer).

## 8. What this does not do

- No email warning before expiry. Tenant email senders are not merged.
  (Email *provisioning* exists and is refused on temporary apps, §4.)
- No byte cap across temporary apps. The storage pass could feed one.
- No temporary binary apps or instances.
- No change to how edge boxes clean their local static cache; that gap
  applies to every app and gets its own discussion.

## 9. Testing

- Store: migration adds the four columns with defaults; `CreateAppIn`
  with an expiry; conditional updates return zero rows when they lose.
- Namespace: `/new`, the browser form, rename and `carlos add` refuse
  `tmp-` names; `/new-temporary` refuses names without it.
- Create route: refuses a name with a route of the same account and
  app and no bucket objects or row (a `carlos add` service); refuses
  when the registry cannot be read; refuses a name with any key under
  its prefix, including
  a manifest with no channel pointer; owner role does not exempt it;
  cap refuses the 21st and is exact under concurrent creates; the CLI
  against a console without the route creates nothing.
- Server paths in §4 each refuse on a temporary row and pass after keep.
- Expiry pass, fake clock and a recording store: expire → trash →
  purge at 3 days; keep between candidate listing and lock acquisition
  (the pass must skip); failure at step 3 retries; failure at step 4
  followed by a keep (keep refused, row settled trashed with the 3-day
  window); restore→keep→trash between decision
  and purge refuses via `PurgeAppIfTrashedAt`; interrupted purge resumes;
  `ErrNotTrashed` with an empty prefix finalises, with objects restores;
  control-plane and email-estate backstops refuse without tearing down.
- Materialize: a marker found for a temporary row sets the 3-day window
  from the marker's clock; for a permanent row, leaves it 0.
- Restore: rewrites expiry before deleting the tombstone and clears the
  deadline only after (order asserted); a failed tombstone delete keeps
  the deadline and releases `restoring`; a failed step 5 holds the cap
  slot until settled; cap enforced on every console restore door.
- `settleApp`: each marker/row/`restoring` combination.
- Empty bucket: start the console against an empty replacement bucket
  with one temporary row awaiting purge and one live but expired; run
  startup materialize and then several expiry passes; no marker is
  written, and both rows survive unchanged. The same with a bucket that
  holds only trash markers.
- Deadline display: a temporary and a permanent app trashed together;
  the Trash view, trash API, CLI output and `/list` each show 3 and 30
  days respectively; direct-mode delete prints both dates from the
  marker, including on a retry two days in and after the 3-day date.
- Settle sweep: manual trash of a temporary app with 30 days left whose
  row update fails; with no restart and no user action, the next pass
  settles it and the early purge runs at 3 days.
- Early purge whose final marker delete fails, with no other keys in
  the bucket: the next pass trips the tripwire, still retries the
  purge, finishes, and removes the row.
- `console grant` refuses a `tmp-` name; backfill skips one already in
  `owners`; `carlos add --app tmp-x` refuses.
- The create route answers 404 until `CARLOS_TEMPORARY_APPS=1`.
- `/list`: trashed apps shown; the accounts listing's `[]string`
  unchanged; service-principal filtering matches.
- Rename, move, control-plane, email, private storage and store doors
  refuse on a temporary row, at the store operation as well as the
  handler.
- Boxes: no change; an existing reconcile test confirms a trashed app
  with no row knowledge still purges at 30 days.
- CLI: `--ttl` implies `--temporary`; local refusals; generated name
  shape and retry.

## 10. Round 1 dispositions

| # | Finding | Disposition |
|---|---|---|
| 1 | Expiry signature validates as production-off | Fixed by §2: no signed bucket record |
| 2 | Five checks miss shipped-unpromoted and legacy state | Fixed by §3: `claimApp` with `ownerClaim=false`, any key refuses |
| 3 | Keep/ttl race the expiry | Fixed by §5: per-app mutex, conditional updates (rev 4 corrects the condition, astra 3.1) |
| 4 | Purge re-read misses deadline | Fixed by §5: `PurgeAppIfTrashedAt` binds to `purge_for` |
| 5 | Deleting expiry does not revoke it | Fixed by §2: no bucket record to replay |
| 6 | Restore bypasses cap | Fixed by §6 for console doors; direct-mode restore accepted |
| 7 | Static-only enforced only in CLI | Fixed by §4: server paths |
| 8 | Browser and headless restore miss expiry | Fixed by §6: shared helper; headless catches up via §5 |
| 9 | Interrupted purge falls back to 30 days | Fixed by §5: decision lives in the row |
| 10 | Migration disables expiry | Fixed by §4: migrate refuses in API mode; direct mode accepted |

## 11. Round 2 dispositions

| # | Finding | Disposition |
|---|---|---|
| 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. |
| R1.10 | Migrate has no API mode | Fixed: §4 now describes migrate as it is and accepts the outcome. |
| 2.1 | Rename puts permanent data under expiry | Fixed: §3 and §4, rename refuses a temporary app |
| 2.2 | Early purge skips mail teardown | Fixed: §4 refuses email on temporary apps; §5 backstop refuses to purge with email estate |
| 2.3 | Failed restore cancels the 3-day window | Fixed: §6 clears the deadline only with the row restore |
| 2.4 | `ErrNotTrashed` is not proof of restore | Fixed: §5 lists the prefix, as materialize does |
| 2.5 | Materialize omits the window | Fixed: §5 materialize rule |
| 2.6 | Instance manifest enables private storage | Fixed: §4 rows for the manifest door and the shared provisioning path |
| 2.7 | Older console makes `--temporary` permanent | Fixed: §3 separate route; CLI checks `expires_at` |
| 2.8 | Expiry bypasses control-plane protection | Fixed: §3 route takes no `control_plane`; §5 backstop |

## 12. Round 3 dispositions

| # | Finding | Disposition |
|---|---|---|
| 2.7 residue | §7 still said `/new` | Fixed: §7 names `/new-temporary` |
| 3.1 | Expiry update matches a kept row (`0 <= now`) | Fixed: §5 re-checks `expires_at > 0` under the mutex after settling |
| 3.2 | Account move puts a permanent site under expiry | Fixed: §4 move refuses, at `MoveApp` |
| 3.3 | `expiring` survives restore | Fixed: `expiring` removed; §2 settle-first rule covers the crash it guarded |
| 3.4 | Failed restore frees a cap slot | Fixed: §6 `restoring` holds the slot until settled |
| 3.5 | Control-plane setter on a temporary app | Fixed: §4 refuses; §5 re-checks before trash as well as purge |
| 3.6 | `apps list` and the listing shape do not exist | Fixed: §7 new subcommand and `/list` endpoint; old contract unchanged |

## 13. Round 4 dispositions

| # | Finding | Disposition |
|---|---|---|
| 4.1 | Absence-based recovery skips the empty-bucket tripwire | Fixed: §2 absence rule, shared tripwire helper; §5 and §6 branches cite it |
| 4.2 | Trash page and API promise 30 days | Fixed: §5 `effectivePurgeAt` used by every displayed deadline |

## 14. Round 5 dispositions

| # | Finding | Disposition |
|---|---|---|
| 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 |
| 5.2 | Expiry's own markers defeat the empty-bucket tripwire | Fixed: §2 a tripped pass writes nothing; the tripwire ignores trash markers |

## 15. Round 6 dispositions

| # | Finding | Disposition |
|---|---|---|
| 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 |
| 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 |
| 5.1 residue | Route check incomplete | Superseded by ruling 7 |

## 16. Round 7 dispositions

| # | Finding | Disposition |
|---|---|---|
| 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 |
| 7.2 | Grant and backfill bypass the namespace | Fixed: §3 table, `Store.Grant` refuses, backfill skips |
| 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 |
| 7.4 | Direct-mode delete promises 30 days | Fixed: §5 direct-mode line no longer promises 30 days |

## 17. Round 8 dispositions

| # | Finding | Disposition |
|---|---|---|
| 8.1 | Failed manual-trash row update escapes both candidate queries | Fixed: §5 settle sweep over every temporary row against the pass's listing |
| 8.2 | Direct-mode retry overpromises | Fixed: §5 dates from the marker's `deleted_at`, elapsed dates said as such |
