carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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 UPDATEs 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": "...", "ttlseconds": N} POST /api/cli/apps/{account}/{app}/ttl {"ttlseconds": 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 |