| 1 | # Temporary static sites |
| 2 | |
| 3 | 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. |
| 4 | Revs 2-9 answer Astra rounds 1-8 |
| 5 | (`CODEX-FINDINGS-TEMPORARY-SITES-R{1..8}.md`). §§10-17 disposition |
| 6 | each finding; the text marks fixes **(astra N)**, **(astra 2.N)** and so |
| 7 | on. |
| 8 | |
| 9 | ## 0. Why |
| 10 | |
| 11 | Two 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 | |
| 18 | Today both become permanent apps. Nothing expires them, there is no |
| 19 | storage quota (only the 256 MiB per-ship cap, `apiShipMaxBytes`), and an |
| 20 | agent in a loop can leave hundreds of apps behind. The opposite risk is |
| 21 | as real: a real site marked temporary by mistake must never quietly |
| 22 | vanish. |
| 23 | |
| 24 | ## 1. Rulings (fixed) |
| 25 | |
| 26 | 1. **The unit is the whole app.** Not a host on an existing app, not a |
| 27 | release. |
| 28 | 2. **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. |
| 32 | 3. **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. |
| 35 | 4. **`carlos apps keep`** makes a temporary app permanent; **`carlos apps |
| 36 | ttl`** moves its expiry. |
| 37 | 5. **Temporary apps get the platform host only.** No custom domains. |
| 38 | 6. **Only a brand-new app can be made temporary.** |
| 39 | 7. **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. |
| 43 | 8. **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 | |
| 53 | Rev 1 put a signed `expiry.json` in the bucket and had boxes act on it. |
| 54 | Every P1 in round 1 traced back to that: a signed record that authorises |
| 55 | destruction can be confused with another signed record (astra 1), |
| 56 | replayed after it is revoked (astra 5), copied by migration (astra 10), |
| 57 | deleted mid-purge (astra 9), and raced by writers that share no lock |
| 58 | (astra 3, 4). |
| 59 | |
| 60 | Rev 2 keeps the lifecycle **in the console's `apps` table** and makes the |
| 61 | console 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 | |
| 114 | This assumes one console per deployment bucket, which is how every |
| 115 | deployment runs today. If the console is down, nothing expires and |
| 116 | nothing early-purges; both fail toward keeping data. |
| 117 | |
| 118 | New 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 |
| 128 | the app was temporary at that moment. Nothing later re-derives them. |
| 129 | |
| 130 | ## 3. Creation: a fresh claim, never an adoption |
| 131 | |
| 132 | Temporary 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 |
| 136 | a field on `/new`, because `/new`'s decoder accepts unknown fields |
| 137 | (`authcli.go:940`): an older console would take `temporary` silently |
| 138 | and create a permanent app. An older console answers the new route with |
| 139 | 404, and the CLI stops there with nothing created **(astra 2.7)**. The |
| 140 | CLI also checks the response carries a non-zero `expires_at` before it |
| 141 | ships. |
| 142 | |
| 143 | The route takes no `control_plane`, `place` or `build_probe`; a |
| 144 | temporary 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 | |
| 223 | There is no door that sets `expires_at` on an existing row. `apps ttl` |
| 224 | only 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 |
| 228 | the *destination* prefix is empty, so a temporary row renamed onto a |
| 229 | name with unclaimed releases would put those releases under expiry. |
| 230 | Keep first, then rename. |
| 231 | |
| 232 | Direct mode (ship straight to the bucket, `ops` only) skips claims |
| 233 | today and cannot create a temporary app. That is fine: the feature is |
| 234 | API-mode only. |
| 235 | |
| 236 | ## 4. Temporary apps stay static and platform-hosted (astra 7) |
| 237 | |
| 238 | Enforced at the **server** mutation paths, not in the CLI, on any row |
| 239 | with `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 | |
| 255 | The rule behind the table: **a temporary row cannot change its name, |
| 256 | its account, or its protection class.** Each of those doors either |
| 257 | points the row at bucket state it did not create (rename, move) or |
| 258 | takes it out of automatic destruction's reach (production, control |
| 259 | plane). The refusals are enforced in the shared store operation where |
| 260 | one exists (`RenameAppIn`, `MoveApp`, `SetAppControlPlane`), not only |
| 261 | in the handlers, and each checks under the per-app mutex. |
| 262 | |
| 263 | Each refusal says `carlos apps keep` first. Keep lifts them all. The |
| 264 | point of the store and email rows is that an automatic purge never |
| 265 | meets backend estate it would need a person to tear down. |
| 266 | Canary hosts for static apps (`reconcile.go:318-340`) still mint; they |
| 267 | are 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 |
| 271 | copy a temporary app as permanent. It is an ops-only verb run by hand, |
| 272 | and that outcome (the copy outlives the original) errs toward keeping |
| 273 | data. Accepted **(astra 10)**. |
| 274 | |
| 275 | ## 5. The expiry pass and the early purge |
| 276 | |
| 277 | A new console loop, `expirypass.go`, beside `economicspass.go` and |
| 278 | `scanpass.go`, on a 5-minute ticker. Every transition below takes the |
| 279 | per-app mutex that keep, ttl, restore, trash and the purge door also |
| 280 | take, 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 |
| 284 | temporary row (`expires_at > 0`) with the one `apps/` listing it has |
| 285 | already taken for the tripwire: a row whose trash state disagrees with |
| 286 | the presence of its marker (live row with a marker, trashed row |
| 287 | without one, or `restoring = 1`) gets `settleApp` under its mutex. This |
| 288 | is what repairs a trash door that wrote the marker and then failed its |
| 289 | row update (`apicli.go:1247`, `:1279`) while the expiry is still far |
| 290 | off, without waiting for a console restart or a user touching the app. |
| 291 | It costs no extra listing, and a GET only for rows that disagree. A |
| 292 | tripped pass still runs the sweep's presence branch (a marker is |
| 293 | evidence) 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 | |
| 298 | 1. `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`. |
| 302 | 2. Refuse, log and audit if the app is the control plane |
| 303 | (`controlPlaneRefusal`) **(astra 3.5)**. §4 makes this unreachable. |
| 304 | 3. `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). |
| 307 | 4. 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 | |
| 311 | A failure at 3 writes nothing, and the next pass tries again. A failure |
| 312 | at 4 leaves a marker and a live row. The mutex has not been released |
| 313 | between 3 and 4, so no keep has run; the next door or pass to touch the |
| 314 | app settles it (the marker wins, with the temporary window, per the |
| 315 | materialize rule below). Keep and ttl settle first, so a keep after a |
| 316 | failed step 4 finds the app in the Trash and is refused ("restore it |
| 317 | first"), rather than succeeding on a row the bucket has already |
| 318 | trashed. No in-progress flag is needed, which removes the round 3 |
| 319 | problem 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 |
| 324 | returns 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 | |
| 334 | The box's own 30-day auto-purge is not a displayed deadline and is |
| 335 | unchanged. |
| 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 |
| 339 | cannot know whether the app is temporary, and the `tmp-` prefix does not |
| 340 | say (kept apps keep it). It stops promising a window it cannot know **(astra 8.2)**. The line |
| 341 | is built from the marker's own `deleted_at`, which `headlessTrash` |
| 342 | already 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 |
| 344 | app, or <T+30d> if not; `carlos apps list` against the console shows |
| 345 | which." A date already in the past is printed as "may be purged at any |
| 346 | time" 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 |
| 353 | trashed 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 |
| 356 | the recovery path when a trash door wrote the marker and failed the row |
| 357 | update. The rule is the same one every door follows: the window is |
| 358 | bound to the marker's clock and to whether the row was temporary when |
| 359 | the 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 | |
| 366 | which 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 |
| 369 | new tombstone with a new clock and a row with `purge_after = 0`, so the |
| 370 | old decision can no longer fire **(astra 4)**. `PurgeReady`'s other |
| 371 | guards (production flag, object-store estate) still apply. On success |
| 372 | the row goes through the existing `PurgeAppRow`. |
| 373 | |
| 374 | Before calling it, the pass refuses, logs and audits (leaving the app |
| 375 | for 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 |
| 383 | tears anything down on its own authority. |
| 384 | |
| 385 | Because the decision lives in the row and not in a bucket object, |
| 386 | an interrupted `PurgeApp` leaves `purge_after` in place and the next |
| 387 | pass 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 |
| 391 | whose `PurgeAppRow` failed, a 30-day box purge, a headless restore, and |
| 392 | a §6 restore that crashed after deleting the marker all look the same |
| 393 | at the marker. The pass tells them apart the way `MaterializeApps` |
| 394 | already 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 | |
| 407 | One 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 | |
| 412 | 1. `settleApp`. Cap check, as §3, counting this app as a new live one. |
| 413 | Over the cap: refuse, nothing changes. |
| 414 | 2. If `expires_at < now + 1h`: `expires_at = now + 7 days`. The row is |
| 415 | still trashed, so this is harmless if what follows fails. |
| 416 | 3. `restoring = 1`. From here the app holds a cap slot **(astra 3.4)**. |
| 417 | 4. `release.RestoreApp` (delete the tombstone). |
| 418 | 5. One statement: `deleted_at = 0`, `restoring = 0`, `purge_after = 0`, |
| 419 | `purge_for = 0`. |
| 420 | |
| 421 | The cap counts rows with `expires_at > 0 AND (deleted_at = 0 OR |
| 422 | restoring = 1)`, so a restore that has deleted the marker but not |
| 423 | reached step 5 still occupies its slot, and no new temporary app can |
| 424 | take 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 | |
| 435 | Step 2 before 4 means the expiry pass never sees an untrashed row with |
| 436 | a stale expiry. Headless `carlos apps restore` in direct mode |
| 437 | writes only the bucket; the row catches up through the `ErrNotTrashed` |
| 438 | path or the start-up materialize, and at that point gets step 2. It |
| 439 | does not get the cap check. Direct mode is ops-only, and that overrun is |
| 440 | accepted. |
| 441 | |
| 442 | ## 7. Commands |
| 443 | |
| 444 | All user-facing strings go through copy review before they are written |
| 445 | into 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 | |
| 457 | API 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 |
| 467 | apps (`store.go:1362`). `/list` returns `[{"app", "deleted_at", |
| 468 | "expires_at", "purge_after"}]` with times as RFC 3339 or null, includes |
| 469 | trashed apps, and applies the same per-principal filtering the accounts |
| 470 | listing applies to service principals (`apicli.go` just above 548). `docs/site/cli.md` gains the flags and both commands. |
| 471 | |
| 472 | The 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 | |