carlos / platform Public

spec rev 9: claim-mode egress deferred, one policy writer per bucket, request budget (Paul, 2026-10-25)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 40642313c952ec148b97703cba82805d6ebb4365 parent 2fdb5d6
1 file changed, +57 −6
  • docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md +57 −6
diff --git a/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md b/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md
index 05bf1da84..1ac945fe8 100644
--- a/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md
+++ b/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md
@@ -1,8 +1,10 @@
# Instance sizes and egress allows
Amadan #328 (an isolated instance reaching a private service its operator
-runs) and #329 (operator-set CPU and memory per instance). Revision 8,
-2026-10-21. Revision 8 folds in what the three plan writers found
+runs) and #329 (operator-set CPU and memory per instance). Revision 9,
+2026-10-25. Revision 9 adds Paul's rulings 9 to 11 (claim-mode
+egress deferred, one policy writer per bucket, a request budget, §3a).
+Revision 8 folds in what the three plan writers found
against the code (§9). Revisions 2 to 7 answer the builder check, Astra rounds
1 to 6, and Paul's ruling on fleet addresses (`CODEX-FINDINGS-instance-sizes-egress-R<n>.md`); the
dispositions are §9. **Approved by Paul, 2026-10-21.**
@@ -36,6 +38,23 @@ written by root onto each instance's own slice**
8. **Fleet box addresses are checked when an allow is granted and
reported afterwards, not enforced continuously by root** (2026-10-21,
§4.2).
+9. **Egress under host claims is deferred** (2026-10-25). On a box with
+ claims on, root writes no allow and reports `egress: not yet
+ supported under claims`. The claim-mode source (root reading and
+ verifying the policy itself, a revision floor, an authenticated app
+ incarnation, durable verified state across restarts) is built with
+ claim activation, as its own thread. Everything in this spec marked
+ "under claims" for egress is that follow-up, not this build. Sizes
+ under claims are unchanged: a new size waits for the next start.
+10. **One policy writer per bucket** (2026-10-25). A console takes a
+ writer lease in the bucket before it signs any policy; a second
+ console refuses policy writes and names the lease holder. Multi-
+ console coordination is follow-up work if a deployment ever needs it.
+11. **S3 requests cost money** (2026-10-25). No reader polls the policy on
+ its own timer. Policy objects map to the #336 change index's app scope
+ (§3a); readers re-read an app's policy only when its generation moves,
+ plus the 30-minute backstop. The steady-state budget is in §3a and is
+ tested.
## 1. Why
@@ -150,7 +169,8 @@ that way.
unverifiable`) and as no override for sizes (the build's tier, from the
next start; never a live shrink). A console down for longer than the
lifetime therefore closes granted addresses, the door keys' trade.
-- **Under claims root reads one policy per app**, not per host, at most
+- **[Deferred by ruling 9; kept as the follow-up's starting point.]**
+ **Under claims root reads one policy per app**, not per host, at most
every 30 seconds, as a conditional read on the ETag, so the loop costs
one small request per app with allows per 30 seconds and nothing when
unchanged. An app with no policy object is re-read every 5 minutes (and on
@@ -172,7 +192,7 @@ that way.
policy exists (its purge, `ownership.go:514`, never touches
`control/app-policy/`; its create, `server.go:4130`, never clears it).
Two rails:
- - **Rollout rule:** no policy is granted until every console that
+ - **Rollout rule** (now enforced by ruling 10's writer lease): no policy is granted until every console that
writes the deployment's bucket runs this version. A deployment runs
one console per bucket today (temporary static sites spec, "one
console per deployment bucket, which is how every deployment runs"),
@@ -181,7 +201,7 @@ that way.
A console downgraded below this version after policies exist must
delete them first; the release notes and the downgrade runbook say
so.
- - **With host claims on, the policy is bound to the app's incarnation:**
+ - **[Deferred by ruling 9.]** **With host claims on, the policy is bound to the app's incarnation:**
it carries the app's `app_id` (`ClaimAppID`, the identity the claim
purge journal and admissions already use, `hostops/admission.go`),
and root ignores a policy whose `app_id` is not the current app's,
@@ -196,6 +216,36 @@ that way.
whole, the plan adds the prefix to the boundary before anything reads
it.
+## 3a. Request budget (ruling 11)
+
+The deployment bucket is the store of record, and every request costs
+money (#336: the October bill was polling that scaled apps × boxes ×
+passes). This feature must add **no request a box makes per pass when
+nothing changed**.
+
+- **Change index scope.** `release.ChangeScope` maps
+ `control/app-policy/<sqid>/<app>.json` to the scope `apps/<sqid>/<app>`
+ (a new row in its table, pinned by its table test), so every policy
+ write bumps the app's generation through the concrete stores' existing
+ hook (#336 spec §3.3). The agent re-reads an app's policy only when
+ that app's generation moved, or on the 30-minute backstop walk, which
+ reads `control/app-policy/` with one LIST and GETs only keys whose ETag
+ differs from the last read.
+- **Root reads no store at all** for egress (pre-claim source is the
+ registry; claim mode is deferred, ruling 9).
+- **Console:** the grant check reads `control/edges/` only on a grant;
+ the report re-runs on a `control/edges` generation change and on the
+ backstop, never on a 15-minute timer. The re-sign pass reads each live
+ policy once per 12 hours, as door keys do.
+- **Sizes** add no store reads: `mem_total_mib` rides the edges file the
+ box already writes, on change only.
+- **Budget, stated and tested:** with no policy changes, the added
+ steady-state cost per box per day is at most 48 LISTs (the backstop's
+ one per 30 minutes) and zero GETs; per console per day, 2 GETs per live
+ policy (re-sign) plus 48 LISTs. Tests count store calls through a
+ counting store wrapper on the agent's reconcile and the console's loops
+ and fail on any increase over that.
+
## 4. Egress allow (#328)
### 4.1 What is accepted
@@ -271,7 +321,8 @@ request (`server.go:33`). Its source follows the trust root already has
for `HostApp`, the lookup that decides which build a nested generation
may run (`validateStagedExe`):
-- **With host claims on**, `HostApp` is store-verified
+- **[Deferred by ruling 9: with claims on, root writes no allow.]**
+ **With host claims on**, `HostApp` is store-verified
(`rootInstallationHostApp`: claim holder, installation, and the record
root reads from the store). Root then reads
`control/app-policy/<sqid>/<app>.json` from the store itself, for that