carlos / platform Public

plans 1-3 reconciled with spec rev 8; cleared-size swap rule and first-grant re-read

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 733c554bf2956ddf05cebb4a457c0c593d3d6094 parent bedb894
6 files changed, +444 −175
  • docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md +215 −72
  • docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md +110 −54
  • docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md +65 −38
  • docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md +15 −7
  • docs/superpowers/plans/CODEX-REVIEW-BRIEF-instance-policy-plans.md +31 −0
  • docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md +8 −4

CI failed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (17 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (150 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.543s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.861s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.772s
ok  	github.com/carlosframework/platform/internal/activator	62.509s
ok  	github.com/carlosframework/platform/internal/agentsock	0.049s
ok  	github.com/carlosframework/platform/internal/alerting	0.008s
ok  	github.com/carlosframework/platform/internal/analytics	0.039s
ok  	github.com/carlosframework/platform/internal/appconfig	2.808s
ok  	github.com/carlosframework/platform/internal/apperr	0.190s
ok  	github.com/carlosframework/platform/internal/applog	0.223s
ok  	github.com/carlosframework/platform/internal/awsinv	0.019s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.017s
ok  	github.com/carlosframework/platform/internal/boxid	0.853s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.018s
ok  	github.com/carlosframework/platform/internal/caps	0.039s
ok  	github.com/carlosframework/platform/internal/certwarm	0.016s
ok  	github.com/carlosframework/platform/internal/cgroup	0.801s
ok  	github.com/carlosframework/platform/internal/changelog	0.591s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.138s
ok  	github.com/carlosframework/platform/internal/clamd	0.060s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.098s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.021s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.030s
ok  	github.com/carlosframework/platform/internal/domains	2.735s
ok  	github.com/carlosframework/platform/internal/doorkey	0.010s
ok  	github.com/carlosframework/platform/internal/economics	0.014s
ok  	github.com/carlosframework/platform/internal/edge	45.318s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.137s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.019s
ok  	github.com/carlosframework/platform/internal/geo	0.009s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.160s
ok  	github.com/carlosframework/platform/internal/host	123.355s
ok  	github.com/carlosframework/platform/internal/hostactors	6.567s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.015s
ok  	github.com/carlosframework/platform/internal/hostbox	2.792s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.812s
ok  	github.com/carlosframework/platform/internal/hostenv	0.015s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.015s
ok  	github.com/carlosframework/platform/internal/hostexec	5.261s
ok  	github.com/carlosframework/platform/internal/hostfence	0.040s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.335s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.019s
ok  	github.com/carlosframework/platform/internal/hostguard	1.029s
ok  	github.com/carlosframework/platform/internal/hostops	1.826s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.015s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.014s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.605s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.211s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.014s
ok  	github.com/carlosframework/platform/internal/infraview	0.016s
ok  	github.com/carlosframework/platform/internal/instancectl	5.225s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.810s
ok  	github.com/carlosframework/platform/internal/instancefs	0.019s
ok  	github.com/carlosframework/platform/internal/instancekey	0.019s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.027s
ok  	github.com/carlosframework/platform/internal/layout	1.724s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.015s
ok  	github.com/carlosframework/platform/internal/lease	10.287s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.557s
ok  	github.com/carlosframework/platform/internal/ledger	0.029s
ok  	github.com/carlosframework/platform/internal/mail	0.303s
ok  	github.com/carlosframework/platform/internal/maintenance	0.088s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.603s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.082s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	22.789s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.952s
ok  	github.com/carlosframework/platform/internal/objstore	0.274s
ok  	github.com/carlosframework/platform/internal/ops	0.229s
ok  	github.com/carlosframework/platform/internal/park	0.299s
ok  	github.com/carlosframework/platform/internal/plans	0.012s
ok  	github.com/carlosframework/platform/internal/privatefile	0.006s
ok  	github.com/carlosframework/platform/internal/provision	22.702s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.612s
ok  	github.com/carlosframework/platform/internal/relay	7.238s
ok  	github.com/carlosframework/platform/internal/release	26.553s
--- FAIL: TestAMoveOntoTheInstalledGoodBuildWaitsForASignedRollback (5.03s)
    --- FAIL: TestAMoveOntoTheInstalledGoodBuildWaitsForASignedRollback/worker (5.01s)
        holdran_test.go:796: timed out waiting for the move onto v1
FAIL
FAIL	github.com/carlosframework/platform/internal/rollout	8.719s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.010s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.065s
ok  	github.com/carlosframework/platform/internal/rootstate	0.028s
ok  	github.com/carlosframework/platform/internal/runs	0.585s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/safefile	0.005s
ok  	github.com/carlosframework/platform/internal/schedule	2.617s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	12.157s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.165s
ok  	github.com/carlosframework/platform/internal/status	0.078s
ok  	github.com/carlosframework/platform/internal/steering	0.007s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.258s
ok  	github.com/carlosframework/platform/internal/svcrole	0.003s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.006s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.005s
ok  	github.com/carlosframework/platform/internal/usage	0.006s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.720s
FAIL
=== internal_console@0 (208 s)
ok  	github.com/carlosframework/platform/internal/console	206.602s
=== internal_console@1 (34 s)
ok  	github.com/carlosframework/platform/internal/console	32.520s
=== internal_console@2 (34 s)
ok  	github.com/carlosframework/platform/internal/console	33.015s
=== internal_console@3 (37 s)
ok  	github.com/carlosframework/platform/internal/console	35.321s
=== internal_console@4 (37 s)
ok  	github.com/carlosframework/platform/internal/console	35.608s
=== internal_console@5 (36 s)
ok  	github.com/carlosframework/platform/internal/console	34.436s
=== internal_console@6 (39 s)
ok  	github.com/carlosframework/platform/internal/console	37.638s
=== internal_console@7 (40 s)
ok  	github.com/carlosframework/platform/internal/console	38.192s
=== root@0 (33 s)
ok  	github.com/carlosframework/platform	31.834s
=== root@1 (44 s)
ok  	github.com/carlosframework/platform	42.675s
=== root@2 (42 s)
ok  	github.com/carlosframework/platform	40.866s

FAILED: rest
make: *** [Makefile:19: test] Error 1

amadan: step gate failed: exit status 2

amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md b/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
index de8fe3485..eb36502f8 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
@@ -2,13 +2,13 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-**Goal:** The operator-only per-app policy object (`control/app-policy/<sqid>/<app>.json`) with its validation, its door, CLI, audit, purge and create-time clear, its read path for fleet-door boxes, and the agent converging it into two registry columns, so plans 2 (egress) and 3 (sizes) only have to read it.
+**Goal:** The operator-only per-app policy object (`control/app-policy/<sqid>/<app>.json`), signed with the release key, with its validation, its door, CLI, audit, re-sign loop, purge and create-time clear, its read path for fleet-door boxes, and the agent verifying it and converging it into two registry columns, so plans 2 (egress) and 3 (sizes) only have to read it.
-**Architecture:** Two small leaf packages (`internal/egressaddr`, `internal/apppolicy`) plus size names in `internal/cgroup` hold every rule. The console writes the object through one CAS door gated by `apiSystemRoller`, auditing first. `provision.Reconcile` reads it once per pass (one LIST, then GETs) and converges `routes.egress_allow` / `routes.size` on every instance row of the app. Nothing on the box enforces anything in this plan.
+**Architecture:** Two small leaf packages (`internal/egressaddr`, `internal/apppolicy`) plus size names in `internal/cgroup` hold every rule. The object is a signed, revisioned, expiring record on the `internal/doorkey` pattern (spec §3, rev 8): the console signs it on every door write with a revision one above the current, and re-signs every live policy on the door keys' 12-hour loop. The console writes it through one CAS door gated by `apiSystemRoller`, auditing first. `provision.Reconcile` reads it once per pass (one LIST, then GETs), verifies it, and converges `routes.egress_allow` / `routes.size` on every instance row of the app. Nothing on the box enforces anything in this plan.
-**Tech Stack:** Go, SQLite (modernc), `net/netip`, the existing `release.Store` contract.
+**Tech Stack:** Go, SQLite (modernc), `net/netip`, `crypto/ed25519`, the existing `release.Store` contract.
-**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 7, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`.
+**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 8, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`.
## Global Constraints
@@ -16,12 +16,12 @@
- Local tests are targeted: only touched packages, with `-run`. Never `make test`, never the whole console or host suite locally; CI runs the full gate (`.amadan/test-gate.sh`).
- New tests in `internal/host`, `internal/activator`, `internal/console`, `moveprobe` call `t.Parallel()` unless they set env, cwd, umask, log output or package vars, reach `edge.SecureUnixListen`, or listen on TCP (activator). No test depends on another having run. A new console test slower than a few seconds refreshes `.amadan/test-weights.tsv` with `.amadan/test-weights.sh`.
- Cross-compile: CI builds darwin and windows. Nothing in this plan may need `//go:build linux`; the new packages are pure Go.
-- Root code: this plan changes packages in root's build graph (`internal/registry`, `internal/provision`, `internal/release`, `internal/cgroup`), so it ends with a census overlay (Task 12), loaded after `plan14-root-reads-ci-speed-activator`.
+- Root code: this plan changes packages in root's build graph (`internal/registry`, `internal/provision`, `internal/release`, `internal/cgroup`, and the new `internal/apppolicy` that plan 2's root imports), so it ends with a census overlay (Task 13), loaded after `plan14-root-reads-ci-speed-activator`.
- Production-wired witnesses: the fleet-door read is tested through a real `edge.Edge` store door and the real `fleetapi` client; the door through the real console mux; reconcile through a real `DirStore` and registry.
-- User-facing strings (CLI output, door error messages a person reads, audit details shown in the activity tab, README prose) are collected in Task 13 for copy review before merge. Plain English, no em dashes. Internal error fragments, log lines and JSON keys are not copy.
+- User-facing strings (CLI output, door error messages a person reads, audit details shown in the activity tab, README prose) are collected in Task 14 for copy review before merge. Plain English, no em dashes. Internal error fragments, log lines and JSON keys are not copy.
- No tenant names in new code, tests or comments: `shop`, `shop-a.example.com`, `10.0.5.20`, `fd00:5::20`. (Existing fixtures that carry other names are reused as they are.)
- Commit per task, message ending `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
-- Nothing is on by default: with no policy object every registry row, fingerprint, edges file, unit and slice is byte-identical to main (Task 11 proves this plan's part).
+- Nothing is on by default: with no policy object every registry row, fingerprint, edges file, unit and slice is byte-identical to main (Task 12 proves this plan's part).
- Pinned names (brief, "Cross-plan interfaces") are used exactly. Names this plan adds are pinned in each task's Interfaces block.
## Review Focus
@@ -30,7 +30,7 @@
2. **A fleet-door box behind an edge that predates the grant** gets a bare 404 for the policy key. Expect: "policy unreadable", columns cleared, never "no policy" silently treated as known. Test: Task 5 `TestPolicyRefusalFromAnOldDoorIsNotAbsence`.
3. **Two operators set the policy at once.** Expect: the second gets 412 and nothing of theirs lands. Test: Task 8 `TestPolicyPutIsCompareAndSwap`.
4. **A transient store failure during a reconcile pass.** Expect: existing columns left as they are (a revoke is not invented, an allow is not dropped); a corrupt or refused object, by contrast, fails closed. Test: Task 7 `TestReconcilePolicyReadFailures`.
-5. **`--egress-allow 10.0.5.20:5432`, a CIDR, a hostname or a public address.** Expect: refused before any request, naming why. Tests: Task 1 `TestParseRefusesNonAddresses`, Task 10 `TestAppsSetPolicyRefusesClientSide`.
+5. **`--egress-allow 10.0.5.20:5432`, a CIDR, a hostname or a public address.** Expect: refused before any request, naming why. Tests: Task 1 `TestParseRefusesNonAddresses`, Task 11 `TestAppsSetPolicyRefusesClientSide`.
## Boundary verification (spec §3 precondition), recorded
@@ -39,8 +39,8 @@ What was checked on main at `b06ce419`, and what this plan does about it:
- **Bucket policy and IAM (`infra/`).** `infra/modules/deployment-buckets/main.tf` carries no Deny on `control/` beyond box evidence and fences. Writes come from IAM grants: the box role (`region-host/main.tf:166`, `region-remote-host/main.tf:85`, `s3:PutObject`/`DeleteObject` on `deployment_bucket/*`) and the console, which runs on a box. Object-store credentials an app receives are scoped to `store_bucket_prefix*` buckets (`region-host/main.tf:606-622`), never the deployment bucket. App tokens, instance tokens, deploy credentials (#229) and member sessions are console bearer tokens with no bucket credential. **So no app, build, instance or member credential can write `control/app-policy/` directly. Holds; no infra change.**
- **Store door (`internal/edge/storedoor.go:529`).** `aclWrite` grants only `fleets/<fleet>/**` and `apps/<sqid>/<app>/{logs,errors,analytics}/**`. It never answers true for `control/`. **Holds;** Task 5 pins it with matrix rows.
- **Console mutation boundary (`internal/hostops/mutation_boundary.go`, `mutation_classifier.go:100-160`).** With host claims on, `s.cfg.Releases` is the boundary, and an unreviewed key is refused (`ErrUnclassified`). `control/app-policy/` is unreviewed today, so **the console's own policy door would be refused under claims.** Task 6 classifies it as app-scoped (the `control/door-keys/` shape) for `Put` and `Delete` only.
-- **Console doors.** After this plan, only three call sites touch the key: the operator door (write), the purge sweep (delete) and app create (delete). Task 6 adds a source test that keeps it so.
-- **Not in the spec's list, but found (for Paul / plan 2):** the agent (`carlos-edge.service`, no `IPAddressDeny`) reaches IMDS and so holds the box role, which can write `control/app-policy/`. The same is true of flat exec children and of `carlos-app@` units granted `host_role`. Before claims this is the trust the spec already gives the agent. **With claims on, spec §4.3 says root reads nothing the agent wrote, but an unsigned policy object is agent-writable.** Plan 1 keeps the pinned unsigned shape. Plan 2 should not rely on it under claims until this is ruled on; the door-key record (`internal/doorkey`, signed with the root release key for exactly this reason, discussion #171) is the precedent if a signature is wanted.
+- **Console doors.** After this plan, only four call sites touch the key: the operator door and the re-sign pass (writes, both in `apipolicy.go`), the purge sweep (delete) and app create (delete). Task 6 adds a source test that keeps it so.
+- **The agent can write the key.** The agent (`carlos-edge.service`, no `IPAddressDeny`) reaches IMDS and so holds the box role, which can write `control/app-policy/`. The same is true of flat exec children and of `carlos-app@` units granted `host_role`. Before claims this is the trust the spec already gives the agent. Under claims it is not, so spec rev 8 §3 makes the object a signed record on the `internal/doorkey` precedent (discussion #171): Task 3 signs and verifies, Task 8 signs on every write, Task 9 re-signs, Task 7 verifies before converging, and plan 2 verifies in root with a revision floor. A bucket writer can then only delete the object, replay an older signed one, or corrupt it; each of those closes allows rather than opening one.
## Interfaces produced for plans 2 and 3
@@ -48,13 +48,15 @@ Everything in the brief's "Cross-plan interfaces", plus:
- `release.AppPolicyPrefix = "control/app-policy/"`, `release.AppPolicyKey(acct, app string) string`. `apppolicy.Prefix` and `apppolicy.Key` are these (release cannot import apppolicy, and the purge sweep lives in release).
- `release.ErrRefused`: a store that can tell a refusal from absence returns an error wrapping it. Only `fleetapi` does, and only for keys under `release.AppPolicyPrefix`.
-- `apppolicy.ErrInvalid`; `apppolicy.PutIfMatchETag(...) (string, error)`; `apppolicy.Present(ctx, s) (map[string]bool, error)`; `(*Policy).SizeFor(host string, createdAt time.Time) string`; `(*Policy).EgressColumn() string`.
+- The signed record (Task 3; the brief pins `Sign`, `Verify`, `VerifyRevision`, `Lifetime`, `Revision`, `SignedAt`): `apppolicy.ErrInvalid`, `ErrUnverifiable`, `ErrExpired`; `MaxClockSkew = 5 * time.Minute`; `func VerifyKey(pub ed25519.PublicKey, wire []byte, now time.Time, sqid, app string) (Policy, error)` (`Verify` plus "the record names this key's account and app"; root uses it on bytes it read itself); `func (p Policy) Expired(now time.Time) bool`; `func GetForUpdate(ctx, s, pub, sqid, app) (*Policy, string, error)` (console only: signed records whether expired or not); `apppolicy.PutIfMatchETag(...) (string, error)`; `apppolicy.Present(ctx, s) (map[string]bool, error)`; `(*Policy).SizeFor(host string, createdAt time.Time) string`; `(*Policy).EgressColumn() string`.
- `egressaddr.MaxAllow = 8`; `cgroup.SizeNames() []string`.
- Store door: a 404 for a granted, absent policy key carries `Carlos-Store-Absent: 1`.
-- Console: `func (s *Server) checkEgressGrant(ctx context.Context, acct *Account, app string, allow []string) (notChecked []string, status int, msg string)` in `internal/console/apipolicy.go`. **Plan 1's body is a stub returning `[]string{}, 0, ""`. Plan 2 fills it** (the fleet-address check, §4.2). It is called only when the request carries `egress_allow` and the new list is non-empty; a non-zero `status` refuses with `msg` before the audit. The reply's `not_checked` is its first return.
-- Plan 3 inserts its capacity check (§5.5) in `handleAPIPolicyPut` after size validation and before `checkEgressGrant`, and its build-tier warning in the CLI.
+- Console: `func (s *Server) checkEgressGrant(ctx context.Context, acct *Account, app string, old, next []string) (notChecked []string, status int, msg string)` in `internal/console/apipolicy.go`. `old` is the stored list, `next` the normalized new one. **Plan 1's body is a stub returning `[]string{}, 0, ""`. Plan 2 fills it** (the fleet-address check, §4.2, on `next` minus `old`). It is called whenever the request carries `egress_allow`; a non-zero `status` refuses with `msg` before the audit. The reply's `not_checked` is its first return.
+- Plan 3 inserts its capacity check (§5.5) in `handleAPIPolicyPut` after size validation and before `checkEgressGrant`. There is no below-tier CLI warning (spec §5.2 rev 8 drops it; the console cannot see a build's `children` bit), and no plan implements one.
- `provision.Reconcile` writes `routes.size` = the configured size only (`''` = nothing configured = the build's tier). Resolving `''` against the build is plan 3's.
-- Policy unreadable (refused or invalid) on the agent path: columns cleared and the log line `provision: <acct>/<app>: policy unreadable: <reason>`. Plan 2 decides how it reaches the status snapshot.
+- `provision.ReconcileConfig.PolicyChanged func(host string)`: called after each successful (not dry-run) `SetPolicy`, nil = no call. Plan 3 sets it to the activator's `NoteSize`.
+- Policy unreadable, unverifiable or expired on the agent path: columns cleared, the log line `provision: <acct>/<app>: policy <word>: <reason>` with `<word>` one of `unreadable`, `unverifiable`, `expired`, and the word published by `func PolicyReadStates() map[string]string` (key `<sqid>/<app>`, value `policy unreadable` / `policy unverifiable` / `policy expired`; absent when the last pass read the app's policy cleanly). Plan 2 Task 11 carries it to the status snapshot.
+- Sidecar members (`Kind "sidecar"` rows, `ReconcileSidecars`) get no policy: both columns stay `''` (spec §4.3 rev 8). Plan 2 counts any such row as making the box not wholly nested.
---
@@ -71,7 +73,7 @@ Everything in the brief's "Cross-plan interfaces", plus:
`FenceDeny`, in this order (the template line with `localhost` and `multicast` expanded the way systemd does):
`127.0.0.0/8 ::1/128 224.0.0.0/4 ff00::/8 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 240.0.0.0/4 64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10`.
-Error text (copy, Task 13), as named constants in the file:
+Error text (copy, Task 14), as named constants in the file:
| const | text |
|---|---|
@@ -201,33 +203,82 @@ func TestSizesTable(t *testing.T) {
---
-### Task 3: `internal/apppolicy` and its key in `release`
+### Task 3: `internal/apppolicy`, the signed record, and its key in `release`
**Files:**
- Modify: `internal/release/store.go:37` (add `ErrRefused` beside `ErrCASConflict`)
- Modify: `internal/release/release.go:255-266` (add `AppPolicyPrefix`, `AppPolicyKey` beside `LedgerAppPrefix`; extend `AppPrefix`'s comment to name it)
-- Create: `internal/apppolicy/apppolicy.go`
-- Test: `internal/apppolicy/apppolicy_test.go`
+- Create: `internal/apppolicy/apppolicy.go` (the object, store calls), `internal/apppolicy/signed.go` (sign and verify)
+- Test: `internal/apppolicy/apppolicy_test.go`, `internal/apppolicy/signed_test.go`
**Interfaces:**
-- Consumes: Task 1 `egressaddr.Parse`, `MaxAllow`; Task 2 `cgroup.SizeByName`.
-- Produces (pinned): `Prefix`, `Key`, `Policy`, `SizeOverride`, `Get`, `PutIfMatch`, `Delete`, `(Policy).Validate`. Added:
+- Consumes: Task 1 `egressaddr.Parse`, `MaxAllow`; Task 2 `cgroup.SizeByName`; `release.ValidAccount`, `release.ValidApp`.
+- Produces (pinned in the brief): `Prefix`, `Key`, `Policy`, `SizeOverride`, `Get`, `PutIfMatch`, `Delete`, `(Policy).Validate`, `Sign`, `Verify`, `VerifyRevision`, `Lifetime`. Their exact forms:
+ - `type Policy struct { V int "v"; Account string "account"; App string "app"; Revision uint64 "revision"; SignedAt time.Time "signed_at"; AppID string "app_id,omitempty"; EgressAllow []string "egress_allow,omitempty"; DefaultSize string "default_size,omitempty"; Sizes map[string]SizeOverride "sizes,omitempty" }` (JSON tags as quoted). `Account` and `App` are the doorkey record's binding: a signed record names the key it was signed for, so a record copied to another app's key is refused (`edge/doorkeys.go:160`).
+ - `const Lifetime = 72 * time.Hour`; `const domain = "carlos app-policy v1\n"` (unexported, as in `doorkey`); `const MaxClockSkew = 5 * time.Minute`
+ - `func Sign(priv ed25519.PrivateKey, p Policy) ([]byte, error)`
+ - `func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Policy, error)`
+ - `func VerifyRevision(pub ed25519.PublicKey, wire []byte) (uint64, error)`
+ - `func VerifyKey(pub ed25519.PublicKey, wire []byte, now time.Time, sqid, app string) (Policy, error)`
+ - `func (p Policy) Expired(now time.Time) bool`: `!now.Before(p.SignedAt.Add(Lifetime))`
+ - `func Get(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string, now time.Time) (*Policy, string, error)`
+ - `func GetForUpdate(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string) (*Policy, string, error)`
+ - `func PutIfMatch(ctx context.Context, s release.Store, priv ed25519.PrivateKey, p Policy, etag string) error`; `func PutIfMatchETag(...same...) (string, error)`
+ - `func Delete(ctx context.Context, s release.Store, sqid, app string) error`
+ - `var ErrInvalid = errors.New("apppolicy: invalid policy")`, `ErrUnverifiable = errors.New("apppolicy: policy unverifiable")`, `ErrExpired = errors.New("apppolicy: policy expired")`
- `release.ErrRefused = errors.New("store: refused")`
- `const release.AppPolicyPrefix = "control/app-policy/"`; `release.AppPolicyKey(acct, app string) string` = `AppPolicyPrefix + acct + "/" + app + ".json"`
- `const apppolicy.Prefix = release.AppPolicyPrefix`; `apppolicy.Key` calls `release.AppPolicyKey`
- - `var ErrInvalid = errors.New("apppolicy: invalid policy")`
- - `func PutIfMatchETag(ctx context.Context, s release.Store, sqid, app string, p Policy, etag string) (string, error)`; `PutIfMatch` is it with the ETag dropped
- `func Present(ctx context.Context, s release.Store) (map[string]bool, error)`: the keys listed under `Prefix`
- `func (p *Policy) SizeFor(host string, createdAt time.Time) string` and `func (p *Policy) EgressColumn() string`, both nil-safe
-Rules:
-- `Get`: absent → `nil, "", err` with `errors.Is(err, os.ErrNotExist)`; any store error passes through wrapped (a `release.ErrRefused` stays matchable); a body that will not decode or fails `Validate` → `nil, etag, err` wrapping `ErrInvalid` (the ETag is returned so the door can overwrite a bad object). Unknown JSON fields are tolerated (a newer console may add some).
-- `Validate` (shape only, per brief): `V == 1`; `DefaultSize` is `""` or a known size; each `Sizes` key passes `controlname.ValidHostShape` and is lower case, its `Size` is a known size and its `CreatedAt` is non-zero; `len(EgressAllow) <= egressaddr.MaxAllow`; each entry `e` satisfies `Parse(e)` with `Parse(e).String() == e`, strictly ascending by `netip.Addr.Compare` (the stored form is canonical). Errors wrap `ErrInvalid`.
-- `PutIfMatchETag` validates first (refuses with `ErrInvalid`), marshals, and calls `s.Put(ctx, Key(...), body, etag)`; `etag == ""` is create-only (the Store contract). `release.ErrCASConflict` passes through.
+Rules (the `internal/doorkey` shape, `doorkey.go:155-240`, kept line for line where it applies):
+- **Wire form:** `{"payload": "<base64 of the Policy JSON>", "sig": "<base64 ed25519 signature over domain + payload>"}`, indented as `doorkey.Sign` writes it. The release key signs other records over the bare base64 payload; the domain line keeps a policy signature from being any of them, and a door-key signature from being a policy.
+- **`Sign`:** a private key of the right size; `p.Validate()`; then the signing fields: `release.ValidAccount(p.Account)`, `release.ValidApp(p.App)`, `Revision > 0`, `!SignedAt.IsZero()`. Any failure wraps `ErrInvalid`.
+- **Opening** (unexported `open`, as in doorkey): signature, decode, `Validate` and the signing fields. Any failure wraps `ErrUnverifiable`. An unsigned object (a bare policy JSON a bucket writer put there) is unverifiable.
+- **`Verify`:** `open`, then `p.Expired(now)` → `ErrExpired`, and `SignedAt` after `now + MaxClockSkew` → `ErrUnverifiable` (a future-dated record would otherwise live past its lifetime).
+- **`VerifyRevision`:** `open` only, expired or not: the publisher's read, so the next write stays above it.
+- **`VerifyKey`:** `Verify`, then `p.Account == sqid && p.App == app`, else `ErrUnverifiable` naming the account and app the record carries.
+- **`Get`** (every box-side reader): absent → `nil, "", err` with `errors.Is(err, os.ErrNotExist)`; any store error passes through wrapped (a `release.ErrRefused` stays matchable); otherwise `VerifyKey(pub, body, now, sqid, app)`, and on its error `nil, etag, err` (`ErrUnverifiable` or `ErrExpired`). The ETag is returned so a caller can tell the object changed. Unknown JSON fields inside the payload are tolerated (a newer console may add some).
+- **`GetForUpdate`** (the console's door and re-sign loop only): as `Get` but with `open` plus the account and app check, no expiry, so an operator's policy outlives a console that was down past `Lifetime` and is re-signed when it returns (the door keys' trade, spec §3).
+- **`Validate`** (shape only, per brief): `V == 1`; `DefaultSize` is `""` or a known size; each `Sizes` key passes `controlname.ValidHostShape` and is lower case, its `Size` is a known size and its `CreatedAt` is non-zero; `len(EgressAllow) <= egressaddr.MaxAllow`; each entry `e` satisfies `Parse(e)` with `Parse(e).String() == e`, strictly ascending by `netip.Addr.Compare` (the stored form is canonical). Errors wrap `ErrInvalid`. It does not look at the signing fields.
+- **`PutIfMatchETag`** signs first (refuses with `ErrInvalid`) and calls `s.Put(ctx, Key(p.Account, p.App), wire, etag)`; `etag == ""` is create-only (the Store contract). `release.ErrCASConflict` passes through. The caller chooses `Revision` and `SignedAt` (Task 8: current + 1, now; Task 9: unchanged, now).
- `SizeFor`: the override for `host` if its `CreatedAt.Equal(createdAt)`, else `DefaultSize`, else `""`. `EgressColumn`: `strings.Join(EgressAllow, " ")`.
- [ ] **Step 1: Write the failing tests**
+`signed_test.go` (a fresh `ed25519.GenerateKey` per test; `p1` = `{V:1, Account:"ab12", App:"shop", Revision:1, SignedAt:now, EgressAllow:["10.0.5.20"]}`):
+
+```go
+func TestSignVerifyRoundTrip(t *testing.T) {
+ // Sign(priv, p1) -> wire; Verify(pub, wire, now) == p1 (SignedAt compared with Equal)
+ // wire decodes as {"payload","sig"}; the payload's JSON carries "revision":1 and "signed_at"
+ // VerifyRevision(pub, wire) == 1
+}
+func TestVerifyRefuses(t *testing.T) {
+ // another key's pub -> ErrUnverifiable
+ // the payload re-encoded with egress_allow ["10.0.5.21"] under the old sig -> ErrUnverifiable
+ // sig over "carlos door-keys v1\n"+payload (doorkey's domain) -> ErrUnverifiable
+ // sig over the bare payload (the release pointer form) -> ErrUnverifiable
+ // `{"v":1,"account":"ab12","app":"shop","egress_allow":["10.0.5.20"]}` (unsigned) -> ErrUnverifiable
+ // `{` -> ErrUnverifiable
+ // now = SignedAt+Lifetime -> ErrExpired; now = SignedAt+Lifetime-time.Nanosecond -> ok
+ // SignedAt = now+6*time.Minute -> ErrUnverifiable; now+4*time.Minute -> ok
+}
+func TestSignRefusesAnUnsignableRecord(t *testing.T) {
+ // Revision 0; zero SignedAt; Account "AB!"; App ""; DefaultSize "huge"; short key -> error, ErrInvalid
+}
+func TestVerifyRevisionIgnoresExpiry(t *testing.T) {
+ // p1 with Revision 7 signed 73 h ago: Verify -> ErrExpired; VerifyRevision -> 7
+ // a tampered wire -> VerifyRevision errors
+}
+func TestVerifyKeyBindsTheRecordToItsKey(t *testing.T) {
+ // p1 (ab12/shop): VerifyKey(..., "ab12", "shop") ok; (..., "ab12", "other") and ("zz99","shop") -> ErrUnverifiable
+}
+```
+
+`apppolicy_test.go`:
+
```go
func TestKeyShape(t *testing.T) {
// Key("ab12","shop") == "control/app-policy/ab12/shop.json" == release.AppPolicyKey("ab12","shop")
@@ -235,9 +286,10 @@ func TestKeyShape(t *testing.T) {
func TestRoundTripAndCAS(t *testing.T) {
s := release.NewDirStore(t.TempDir())
// Get on an empty store: nil policy, errors.Is(err, os.ErrNotExist)
- // PutIfMatchETag(..., p{V:1, EgressAllow:["10.0.5.20"], DefaultSize:"large"}, "") -> etag1
+ // PutIfMatchETag(ctx, s, priv, p{V:1, Account:"ab12", App:"shop", Revision:1, SignedAt:now,
+ // EgressAllow:["10.0.5.20"], DefaultSize:"large"}, "") -> etag1
// PutIfMatch(..., same, "") -> errors.Is(err, release.ErrCASConflict) // create-only
- // Get -> p equal, etag1; PutIfMatch(..., p2, etag1) ok; PutIfMatch(..., p3, etag1) -> ErrCASConflict
+ // Get(ctx, s, pub, "ab12", "shop", now) -> p equal, etag1; PutIfMatch(..., p2, etag1) ok; PutIfMatch(..., p3, etag1) -> ErrCASConflict
// Delete; Get -> os.ErrNotExist; Delete again -> nil
}
func TestValidate(t *testing.T) {
@@ -247,9 +299,11 @@ func TestValidate(t *testing.T) {
// Sizes key "Shop-A.example.com" and "../x"; nine allows; ["fd00:5::20","10.0.5.20"] (unsorted);
// ["10.0.5.20","10.0.5.20"]; ["10.0.5.20:80"]; ["::ffff:10.0.5.20"] (not canonical)
}
-func TestGetRefusesAnInvalidBodyButReturnsItsETag(t *testing.T) {
- // seed `{"v":1,"default_size":"huge"}` -> Get: nil, non-empty etag, errors.Is(err, ErrInvalid)
- // seed `{` -> same
+func TestGetRefusesWhatItCannotVerifyButReturnsItsETag(t *testing.T) {
+ // seed `{` -> Get: nil, non-empty etag, errors.Is(err, ErrUnverifiable)
+ // seed an unsigned policy JSON -> same
+ // seed p1 signed 73 h ago -> Get: nil, etag, ErrExpired; GetForUpdate -> the policy and its etag
+ // seed p1 for ab12/other at Key("ab12","shop") -> Get and GetForUpdate: ErrUnverifiable
}
func TestSizeForHonoursTheBinding(t *testing.T) {
// p := {V:1, DefaultSize:"large", Sizes:{"shop-a.example.com": {"xlarge", t0}}}
@@ -263,9 +317,9 @@ func TestPresentListsPolicies(t *testing.T) { /* two seeded keys -> both in the
```
- [ ] **Step 2: Run, fail:** `go test ./internal/apppolicy -run .` → FAIL (package missing).
-- [ ] **Step 3: Implement** the release additions and `apppolicy.go` (package comment: one operator-only object per app, spec §3; written only by the console's policy door; read by the agent before claims and by root after).
+- [ ] **Step 3: Implement** the release additions, `signed.go` (file comment: the door-key precedent, spec §3 rev 8; why the domain line; why `Account`/`App` are inside the signature) and `apppolicy.go` (package comment: one operator-only object per app, spec §3; written only by the console's policy door and re-sign loop; verified by the agent before claims and by root after).
- [ ] **Step 4: Run, pass:** `go test ./internal/apppolicy -run . && go test ./internal/release -run 'TestPurge|TestTrash'` (the release edit must not break purge).
-- [ ] **Step 5: Commit** `apppolicy: the operator policy object (#328, #329)`.
+- [ ] **Step 5: Commit** `apppolicy: the signed operator policy record (#328, #329)`.
---
@@ -354,8 +408,8 @@ func TestStoreDoorMarksOnlyAGrantedAbsentPolicy(t *testing.T) {
```go
func TestPolicyThroughTheDoor(t *testing.T) {
- // own key absent: apppolicy.Get(ctx, d.box(t), testSqid, "web") -> errors.Is(err, os.ErrNotExist)
- // seed a valid policy at release.AppPolicyKey(testSqid,"web"): Get -> the policy and its etag
+ // own key absent: apppolicy.Get(ctx, d.box(t), pub, testSqid, "web", now) -> errors.Is(err, os.ErrNotExist)
+ // seed a policy signed with the test key at release.AppPolicyKey(testSqid,"web"): Get -> the verified policy and its etag
// another account's key: Get -> errors.Is(err, release.ErrRefused) && !errors.Is(err, os.ErrNotExist)
// Put and Delete of the own key through d.box(t) -> error; the seeded object unchanged
}
@@ -399,10 +453,10 @@ func TestClassifyAppPolicyIsExact(t *testing.T) {
```go
// writers_test.go: the boundary of spec §3 kept by source. Walks every non-test .go file
// under the module root (go/parser, as apps_swap_constants_test.go does) and records each
-// call of apppolicy.PutIfMatch, apppolicy.PutIfMatchETag, apppolicy.Delete and
+// call of apppolicy.PutIfMatch, apppolicy.PutIfMatchETag, apppolicy.Sign, apppolicy.Delete and
// release.AppPolicyKey outside internal/apppolicy.
func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
- // PutIfMatch/PutIfMatchETag: only internal/console/apipolicy.go
+ // PutIfMatch/PutIfMatchETag/Sign: only internal/console/apipolicy.go (the door, Task 8, and the re-sign pass, Task 9)
// Delete: only internal/console/server.go (create-time clear)
// release.AppPolicyKey: only internal/release/trash.go, internal/edge/storedoor.go,
// internal/hostops/mutation_classifier.go
@@ -410,7 +464,7 @@ func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
}
```
-- [ ] **Step 2: Run, fail:** `go test ./internal/hostops -run TestClassifyAppPolicyIsExact` → FAIL (unclassified). (The writers test passes vacuously until Tasks 8 and 9 add the calls; it is written now so they land under it.)
+- [ ] **Step 2: Run, fail:** `go test ./internal/hostops -run TestClassifyAppPolicyIsExact` → FAIL (unclassified). (The writers test passes vacuously until Tasks 8, 9 and 10 add the calls; it is written now so they land under it.)
- [ ] **Step 3: Implement** the case with `appScoped("app-policy", p[2], strings.TrimSuffix(p[3], ".json"))` after checking `len(p) == 4`, the `.json` suffix, the method (`Put` or `Delete`), and `t.Key == release.AppPolicyKey(...)`.
- [ ] **Step 4: Run, pass:** `go test ./internal/hostops -run 'TestClassify|TestReviewed' && go test ./internal/apppolicy -run TestPolicyWriters`.
- [ ] **Step 5: Commit** `hostops: classify the app policy as app-scoped (#328)`.
@@ -421,23 +475,27 @@ func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
**Files:**
- Modify: `internal/provision/reconcile.go`: the prefetch block (954-1040) gains the policy read; `convergeOwned` (706-816) gains a parameter and a converge arm after the update-settings arm; its four call sites (1302, 1436, 1451, and the adoption arm at 1419-1436); the main mint (1253-1293); `mintHeld` (1580-1620)
+- Modify: `internal/provision/reconcile.go:36-110` (`ReconcileConfig` gains `PolicyChanged`; `ReleasePub` is already there, line ~94)
- Modify: `internal/provision/seedswap.go:150-161` (`swapImport` carries the columns on the import's row)
-- Create: `internal/provision/apppolicy_reconcile.go` (the read and the column derivation)
+- Create: `internal/provision/apppolicy_reconcile.go` (the read, the verification, the column derivation and `PolicyReadStates`)
- Test: `internal/provision/apppolicy_reconcile_test.go`
**Interfaces:**
-- Consumes: Task 3 (`apppolicy.Present`, `Get`, `ErrInvalid`, `SizeFor`, `EgressColumn`, `release.ErrRefused`), Task 4 (`SetPolicy`, the two `Route` fields).
-- Produces (this plan's names):
- - `type appPolicyCols struct { EgressAllow, Size string; Known bool }`
+- Consumes: Task 3 (`apppolicy.Present`, `Get`, `ErrUnverifiable`, `ErrExpired`, `SizeFor`, `EgressColumn`, `release.ErrRefused`), Task 4 (`SetPolicy`, the two `Route` fields), `cfg.ReleasePub` (the box's `CARLOS_RELEASE_PUBKEY`, already in `ReconcileConfig`).
+- Produces:
+ - `ReconcileConfig.PolicyChanged func(host string)` (cross-plan: plan 3 sets it to the activator's `NoteSize`)
+ - `func PolicyReadStates() map[string]string` (cross-plan: plan 2 Task 11 reads it): a copy of the last pass's per-app problems, key `<sqid>/<app>`, value `policy unreadable`, `policy unverifiable` or `policy expired`; an app read cleanly (present or absent) has no entry. Replaced whole at the end of each pass, under a mutex.
+ - (this plan's names) `type appPolicyCols struct { EgressAllow, Size string; Known bool }`
- `func policyColsFor(r policyRead, rec Record) appPolicyCols`
- - `type policyRead struct { p *apppolicy.Policy; state policyState }` with `policyState` one of `policyKnown` (present or absent), `policyRefused`, `policyInvalid`, `policyTransient`
+ - `type policyRead struct { p *apppolicy.Policy; state policyState }` with `policyState` one of `policyKnown` (verified, or absent), `policyRefused`, `policyUnverifiable`, `policyExpired`, `policyTransient`
- `convergeOwned(reg, existing, rec, policy, policyKnown, ap appPolicyCols, dryRun)`; `mintHeld(..., policy, policyKnown, ap appPolicyCols)`; `swapImport(..., policy, ap appPolicyCols)`
Rules (decisions; spec §4.3 "before claims"):
- **Read once per pass.** `apppolicy.Present` once; if it succeeds, `Get` only the keys present among the pass's untrashed apps (absent from the listing = known, no policy). If it fails (a fleet-door box's door does not list `control/`; a store blip), `Get` per app. Prefetched in the existing `forEach` beside the manifest, eight at a time. This keeps the cost on a fleet with no policies at one LIST per pass (memory: S3 request costs).
-- **States to columns:** `policyKnown` → `{p.EgressColumn(), p.SizeFor(rec.Host, rec.CreatedAt), true}` (nil `p` gives `{"", "", true}`). `policyRefused` and `policyInvalid` → `{"", "", true}`: fail closed, log `provision: %s/%s: policy unreadable: %v` once per app per pass. `policyTransient` → `Known: false`: existing rows keep their columns; a new row is minted with `''` (no allow, the build's tier) and converges on the next readable pass. A mint never waits for the policy (unlike the update policy).
-- **Scope:** instance rows from `Reconcile` only. `ReconcileSidecars` rows (sidecar members, `*.sidecar.internal`) stay `''` in this plan (flagged to Paul). `app_id` is not checked on this path (no store-visible incarnation before claims; spec §3).
-- **Converge** both columns in one `SetPolicy` when either differs, dry-run aware, logging `converged %s policy egress %q -> %q, size %q -> %q` in the style of the update-settings arm.
+- **Verify before converging** (spec §3 rev 8): every read is `apppolicy.Get(ctx, s, cfg.ReleasePub, sqid, app, cfg.Now())`, so a column is only ever written from a record the release key signed for this account and app and that has not expired. A nil `ReleasePub` makes every present policy `policyUnverifiable`.
+- **States to columns:** `policyKnown` → `{p.EgressColumn(), p.SizeFor(rec.Host, rec.CreatedAt), true}` (nil `p` gives `{"", "", true}`). `policyRefused`, `policyUnverifiable` and `policyExpired` → `{"", "", true}`: fail closed (no allow; sizes at the build's tier from the next start, which plan 3 never applies as a live memory shrink), log `provision: %s/%s: policy <word>: %v` once per app per pass with `<word>` `unreadable`, `unverifiable` or `expired`, and record the word in `PolicyReadStates`. `policyTransient` → `Known: false`: existing rows keep their columns; a new row is minted with `''` (no allow, the build's tier) and converges on the next readable pass. A mint never waits for the policy (unlike the update policy).
+- **Scope:** instance rows from `Reconcile` only. Sidecar members (`Kind "sidecar"` rows from `ReconcileSidecars`, `*.sidecar.internal`) get no policy: both columns stay `''` (spec §4.3 rev 8; plan 2 counts any such row as making the box not wholly nested, so no allow is written on that box at all). `app_id` is not checked on this path (no store-visible incarnation before claims; spec §3).
+- **Converge** both columns in one `SetPolicy` when either differs, dry-run aware, logging `converged %s policy egress %q -> %q, size %q -> %q` in the style of the update-settings arm, then call `cfg.PolicyChanged(host)` when it is set.
- [ ] **Step 1: Write the failing tests**
@@ -445,10 +503,24 @@ A fixture beside `newUpdatesFixture` (`updates_reconcile_test.go:22`), with neut
```go
func TestReconcileCarriesThePolicyToTheRow(t *testing.T) {
- // policy {V:1, EgressAllow:["10.0.5.20","fd00:5::20"], DefaultSize:"large"} put with PutIfMatch
+ // cfg.ReleasePub = the test key's pub; cfg.PolicyChanged records hosts
+ // policy {V:1, Account, App, Revision:1, SignedAt:now, EgressAllow:["10.0.5.20","fd00:5::20"],
+ // DefaultSize:"large"} put with PutIfMatch(ctx, s, priv, p, "")
// one pass mints the row with EgressAllow "10.0.5.20 fd00:5::20", Size "large"
- // override shop-a -> {xlarge, t0}; next pass -> Size "xlarge"
- // apppolicy.Delete; next pass -> both ""
+ // override shop-a -> {xlarge, t0} (Revision 2); next pass -> Size "xlarge"; PolicyChanged saw shop-a once
+ // apppolicy.Delete; next pass -> both ""; PolicyReadStates() has no entry for the app
+}
+func TestReconcileVerifiesBeforeConverging(t *testing.T) {
+ // start from a converged row ("10.0.5.20", "large"), then replace the object with:
+ // an unsigned policy JSON granting "10.0.5.21" -> row "" "", PolicyReadStates()[sqid+"/shop"] == "policy unverifiable"
+ // a policy signed by another key -> same
+ // the right key's record for app "other" copied to shop's key -> same
+ // the right record signed Lifetime+1m before cfg.Now() -> row "" "", state "policy expired"
+ // cfg.ReleasePub nil with a valid record -> row "" "", "policy unverifiable"
+}
+func TestReconcileGivesSidecarMembersNoPolicy(t *testing.T) {
+ // a valid policy for the app and a sidecar member of it (ReconcileSidecars) -> the sidecar row's
+ // EgressAllow and Size stay ""
}
func TestReconcileIgnoresAnOverrideForAnotherIncarnation(t *testing.T) {
// override shop-a -> {xlarge, t0.Add(-time.Hour)}, DefaultSize "small" -> row Size "small"
@@ -456,8 +528,8 @@ func TestReconcileIgnoresAnOverrideForAnotherIncarnation(t *testing.T) {
func TestReconcilePolicyReadFailures(t *testing.T) {
// start from a converged row ("10.0.5.20", "large"), then wrap the DirStore:
// transient: Get of the policy key returns a plain error and List of Prefix fails -> row unchanged
- // refused: Get returns fmt.Errorf("x: %w", release.ErrRefused) -> row "" ""
- // invalid: the object's bytes are `{` -> row "" ""
+ // refused: Get returns fmt.Errorf("x: %w", release.ErrRefused) -> row "" "", state "policy unreadable"
+ // unverifiable: the object's bytes are `{` -> row "" ""
// a new record minted during the transient pass -> its row is "" "" and converges on the next good pass
}
func TestReconcileReadsPoliciesWithOneListing(t *testing.T) {
@@ -467,7 +539,7 @@ func TestReconcileReadsPoliciesWithOneListing(t *testing.T) {
func TestReconcileDryRunWritesNoPolicy(t *testing.T) { /* cfg.DryRun: row columns unchanged */ }
```
-- [ ] **Step 2: Run, fail:** `go test ./internal/provision -run 'TestReconcileCarriesThePolicy|TestReconcileIgnoresAnOverride|TestReconcilePolicyReadFailures|TestReconcileReadsPoliciesWithOneListing|TestReconcileDryRunWritesNoPolicy'`.
+- [ ] **Step 2: Run, fail:** `go test ./internal/provision -run 'TestReconcileCarriesThePolicy|TestReconcileVerifiesBeforeConverging|TestReconcileGivesSidecarMembersNoPolicy|TestReconcileIgnoresAnOverride|TestReconcilePolicyReadFailures|TestReconcileReadsPoliciesWithOneListing|TestReconcileDryRunWritesNoPolicy'`.
- [ ] **Step 3: Implement** as the rules above. The `RouteFor` signature does not change: the mint sites set `nr.EgressAllow, nr.Size` from `policyColsFor` after calling it, so the adoption comparisons (`shapeMatches`) are untouched.
- [ ] **Step 4: Run, pass**, then the neighbours: `go test ./internal/provision -run 'TestReconcile|TestUpdates|TestSeed|TestHold|TestEverywhere'`.
- [ ] **Step 5: Commit** `provision: converge the app policy into the registry (#328, #329)`.
@@ -478,27 +550,30 @@ func TestReconcileDryRunWritesNoPolicy(t *testing.T) { /* cfg.DryRun: row column
**Files:**
- Create: `internal/console/apipolicy.go`
+- Create: `internal/console/apppolicy_store.go` (the revision high-water table, in the shape of `door_key_revisions`, `doorkeys_store.go:33-70`)
- Modify: `internal/console/authcli.go` (register after the swap door, line 911)
- Modify: `internal/console/svcroutes_test.go` (`closedDoors`, line 95: both routes, with a comment)
- Modify: `internal/console/claim_invariant_fixtures_test.go` (a door fixture for the PUT beside the swap one at line 1809, a `claimRead` for the GET near line 804) and `claim_invariant_probes_read_test.go` / the probe table (a read probe for the GET)
- Test: `internal/console/apipolicy_test.go`
**Interfaces:**
-- Consumes: Tasks 1-3, 6.
+- Consumes: Tasks 1-3, 6; `s.cfg.Key` (the release signing key, as `apidoorkeys.go:142` signs with it) and `doorKeyWritable` (`apidoorkeys.go:184`, its approved 503 messages reused).
- Produces:
+ - `CREATE TABLE IF NOT EXISTS app_policy_revisions (account_id INTEGER NOT NULL, app TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY (account_id, app))`; `func (s *Store) AppPolicyRevisionContext(ctx context.Context, accountID int64, app string) (uint64, error)` (0 when no row); `func (s *Store) RaiseAppPolicyRevisionContext(ctx context.Context, accountID int64, app string, rev uint64) error` (`revision = MAX(revision, rev)`). The purge leaves the row: revisions only rise, so a recreated app's first policy is above every record its predecessor had. This is the doorkey database counter's job (`apidoorkeys.go:120-133`): a bucket writer can corrupt the object and lose its revision, or put an older signed record back, and the console must neither sign below a revision it already signed nor build on a record older than one it wrote.
+ - `func (s *Server) policyLock(sqid, app string) *sync.Mutex` (the app's policy mutex, created on first use as `doorPublishLock` is; Task 9 shares it)
- `GET /api/cli/apps/{account}/{app}/policy` → `handleAPIPolicyGet`; `PUT` same path → `handleAPIPolicyPut`. Both `svcrole.Set{}`.
- Request (strict reader, 8 KiB): `type policyPutReq struct { EgressAllow *[]string `json:"egress_allow"`; DefaultSize *string `json:"default_size"`; Size *policySizeReq `json:"size"` }`, `type policySizeReq struct { Host string `json:"host"`; Value string `json:"value"` }`. `null` or absent leaves a field; `[]` clears the allow; `""` clears the default size; `value: "default"` removes the override.
- Reply: `type policyReply struct { Policy apppolicy.Policy `json:"policy"`; ETag string `json:"etag"`; NotChecked []string `json:"not_checked"`; Stale []string `json:"stale_overrides,omitempty"` }`; the `ETag` header carries the same value. An absent object reads as `{V:1}` with `etag ""`.
- - `checkEgressGrant` (stub; see "Interfaces produced"), `auditRequired(ctx context.Context, acct *Account, app, addr, detail string) error` (wraps `s.store.RecordEventContext`, action `"app-policy"`).
+ - `checkEgressGrant(ctx, acct, app, old, next []string)` (stub; see "Interfaces produced"), `auditRequired(ctx context.Context, acct *Account, app, addr, detail string) error` (wraps `s.store.RecordEventContext`, action `"app-policy"`).
PUT order (each refusal writes nothing):
-1. `apiSystemRoller` (bare 404). 2. `AccountBySqid(r.PathValue("account"))`, then `HasAppIn`: either missing → bare 404. 3. `allowWrite` → 429 `apiSwapLimited`. 4. No bucket → 503. 5. Body; nothing set → 400. 6. Precondition: `If-Match: <etag>` or `If-None-Match: *` (= `""`); neither → 428. 7. `apppolicy.Get`: absent → `{V:1}`, `""`; `ErrInvalid` → `{V:1}` with the returned ETag (the operator's repair path; logged); other error → 502. The precondition must equal the current ETag, else 412. 8. Apply: `egressaddr.Normalize` (400 with its message); `default_size` via `cgroup.SizeByName` or `""`; `size`: `ValidHostShape` (400 `invalid host`), `provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, strings.ToLower(host))` (absent → 404 `no instance %s in this app`), then set `{value, rec.CreatedAt}` or delete. Drop every other override whose record is absent or whose `CreatedAt` differs (the spec's "removes the entry, for tidiness", done here so no member door writes the object). 9. With `s.cfg.HostClaim`, `p.AppID = s.store.ClaimAppID(acct.ID, app)` (error → 503); without, `AppID` stays as read (empty). 10. `checkEgressGrant` when `egress_allow` was sent and is non-empty. 11. `s.appMutation(ctx, acct, app, "app-policy", ...)`: inside, `auditRequired` first (failure → 503, no write), then `PutIfMatchETag`; `ErrCASConflict` → 412; other → 502, and a best-effort `s.audit(..., "app-policy-not-applied", ...)` row. 12. `s.bumpSerial(ctx)`. 13. 200 with the reply.
+1. `apiSystemRoller` (bare 404). 2. `AccountBySqid(r.PathValue("account"))`, then `HasAppIn`: either missing → bare 404. 3. `allowWrite` → 429 `apiSwapLimited`. 4. `doorKeyWritable`: no signing key or no bucket → 503 with its message. 5. Body; nothing set → 400. 6. Precondition: `If-Match: <etag>` or `If-None-Match: *` (= `""`); neither → 428. 7. Take `policyLock(acct.Sqid, app)` until the reply. `apppolicy.GetForUpdate(ctx, s.cfg.Releases, pub, acct.Sqid, app)` and `AppPolicyRevisionContext` (`h`): absent → `{V:1}`, `""`, stored revision 0; a signed record (expired or not) whose revision is at least `h` → it, its ETag, its revision; `ErrUnverifiable`, or a signed record below `h` (an older record put back by a bucket writer) → `{V:1}` with the returned ETag and stored revision 0 (the operator's repair path; logged `console: app policy %s/%s: not building on the stored object: %v`); other error → 502. The precondition must equal the current ETag, else 412. `next := max(stored revision, h) + 1`. 8. Apply: `egressaddr.Normalize` (400 with its message); `default_size` via `cgroup.SizeByName` or `""`; `size`: `ValidHostShape` (400 `invalid host`), `provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, strings.ToLower(host))` (absent → 404 `no instance %s in this app`), then set `{value, rec.CreatedAt}` or delete. Drop every other override whose record is absent or whose `CreatedAt` differs (the spec's "removes the entry, for tidiness", done here so no member door writes the object). 9. With `s.cfg.HostClaim`, `p.AppID = s.store.ClaimAppID(acct.ID, app)` (error → 503); without, `AppID` stays as read (empty). 10. `checkEgressGrant(ctx, acct, app, old.EgressAllow, p.EgressAllow)` whenever `egress_allow` was sent. 11. `s.appMutation(ctx, acct, app, "app-policy", ...)`: inside, `auditRequired` first (failure → 503, no write), then set `p.Account, p.App, p.Revision, p.SignedAt = acct.Sqid, app, next, time.Now().UTC()` and `PutIfMatchETag(ctx, s.cfg.Releases, s.cfg.Key, p, etag)`; `ErrCASConflict` → 412; other → 502, and a best-effort `s.audit(..., "app-policy-not-applied", ...)` row. After a successful write, `RaiseAppPolicyRevisionContext(next)` (a failure is logged and the write stands: the bucket's revision then leads, and Task 9 and the next write raise the table past it; raising before the write would leave the table above a write that failed, and Task 9 would then refuse to re-sign the live record). 12. `s.bumpSerial(ctx)`. 13. 200 with the reply.
-Audit detail (copy, Task 13): the changed parts joined with `"; "`, each `egress allow: <old> -> <new>`, `default size: <old> -> <new>`, `size of <host>: <old> -> <new>`, with `none`, `the build's size` and `the app default` for empty values, ending ` (cli)`.
+Audit detail (copy, Task 14): the changed parts joined with `"; "`, each `egress allow: <old> -> <new>`, `default size: <old> -> <new>`, `size of <host>: <old> -> <new>`, with `none`, `the build's size` and `the app default` for empty values, ending ` (cli)`.
-GET: steps 1-2, then `Get`; `ErrInvalid` → 200 with `{V:1}`, the ETag, and `stale_overrides` empty (logged); `Stale` lists override hosts whose record is absent or whose `CreatedAt` differs.
+GET: steps 1-2, then `GetForUpdate` and the high-water check as in step 7; unverifiable or below the high-water mark → 200 with `{V:1}`, the ETag, and `stale_overrides` empty (logged). An expired record is shown as stored (its `signed_at` says when). `Stale` lists override hosts whose record is absent or whose `CreatedAt` differs.
-Door messages (copy, Task 13): `nothing to set: send egress_allow, default_size or size`; `default_size must be small, standard, large or xlarge, or empty for the build's size`; `size must be small, standard, large, xlarge or default`; `no instance %s in this app`; `send If-Match with the policy's etag, or If-None-Match: * when there is none`; `the policy changed since it was read. Read it again and retry`; `the change could not be recorded, so it was not made. Try again`; `could not save the policy. Try again`; `could not read the policy. Try again`.
+Door messages (copy, Task 14): `nothing to set: send egress_allow, default_size or size`; `default_size must be small, standard, large or xlarge, or empty for the build's size`; `size must be small, standard, large, xlarge or default`; `no instance %s in this app`; `send If-Match with the policy's etag, or If-None-Match: * when there is none`; `the policy changed since it was read. Read it again and retry`; `the change could not be recorded, so it was not made. Try again`; `could not save the policy. Try again`; `could not read the policy. Try again`.
- [ ] **Step 1: Write the failing tests** (each `t.Parallel()`; `newConsoleFixture`, `paul@keymail.dev` is the ops owner; a local helper `policyReq(t, fx, method, app, token, body string, hdr map[string]string) (int, http.Header, string)`)
@@ -538,8 +613,27 @@ func TestPolicyPutRefusesWhenTheAuditFails(t *testing.T) {
// close fx.st's database handle (or a store fault seam if one exists) -> 503 with the audit message,
// object not written
}
+func TestPolicyPutSignsOneRevisionAboveTheCurrent(t *testing.T) {
+ // PUT -> the stored bytes pass apppolicy.Verify(fx's release pub, wire, now) and VerifyKey for this
+ // account and app; Revision 1; SignedAt within the test's window; AppPolicyRevisionContext == 1
+ // second PUT -> Revision 2
+ // RaiseAppPolicyRevisionContext(7) by hand -> next PUT Revision 8
+ // a record signed by the fixture's key at Revision 9 put at the key directly -> next PUT Revision 10, table 10
+}
+func TestPolicyPutDoesNotBuildOnAReplayedRecord(t *testing.T) {
+ // PUT egress ["10.0.5.20"] (rev 1, wire w1); PUT egress [] (rev 2); put w1 back at the key
+ // GET -> policy {v:1} with no egress_allow
+ // PUT If-Match <that etag> {"default_size":"small"} -> stored Revision 3, no egress_allow, default_size small
+}
func TestPolicyPutRepairsAnInvalidObject(t *testing.T) {
- // seed `{` at the key; GET -> 200 {v:1} with an etag; PUT If-Match that etag -> 200 and a valid object
+ // seed `{`, then an unsigned `{"v":1,"egress_allow":["10.0.5.20"]}`, at the key:
+ // GET -> 200 {v:1} with an etag; PUT If-Match that etag -> 200, a verifiable object, no egress allow
+}
+func TestPolicyDoorRefusesWithoutASigningKey(t *testing.T) {
+ // fixture with cfg.Key nil -> PUT 503 with doorKeyReadOnlyMsg; nothing written, no event
+}
+func TestPolicyGetShowsAnExpiredRecord(t *testing.T) {
+ // a record signed by the fixture's key 73 h ago -> GET 200 with its egress_allow and signed_at
}
func TestPolicyPutStampsTheAppIDUnderClaims(t *testing.T) {
// claim-enabled fixture (newClaimCase): PUT -> stored app_id == s.store.ClaimAppID(acct, app)
@@ -547,13 +641,62 @@ func TestPolicyPutStampsTheAppIDUnderClaims(t *testing.T) {
```
- [ ] **Step 2: Run, fail:** `go test ./internal/console -run 'TestPolicy|TestClaimConsoleMutationInvariant|TestNoRoleReaches|TestAdminIsRefusedAtAClosedDoor'`. Expected: the new tests fail; the census tests fail on the unregistered routes once registered.
-- [ ] **Step 3: Implement** `apipolicy.go` (file comment: spec §6, operator-only, why the zero Set, why the audit is not best-effort), the registration (comment: `apiSystemRoller` in the handler, the zero Set at the mux), the `closedDoors` lines, the claim fixture (`apps: claimRefs("bab/titogo")`, a PUT with `If-None-Match: *` and a body setting `default_size`; add a header-capable request helper next to `claimAPI` if none exists) and the read probe.
+- [ ] **Step 3: Implement** `apppolicy_store.go` (migration beside the door-key tables' and the two methods), `apipolicy.go` (file comment: spec §6, operator-only, why the zero Set, why the audit is not best-effort, why the door signs with a revision above both the bucket's and the table's), the registration (comment: `apiSystemRoller` in the handler, the zero Set at the mux), the `closedDoors` lines, the claim fixture (`apps: claimRefs("bab/titogo")`, a PUT with `If-None-Match: *` and a body setting `default_size`; add a header-capable request helper next to `claimAPI` if none exists) and the read probe.
- [ ] **Step 4: Run, pass.** Same command.
- [ ] **Step 5: Commit** `console: the app policy door (#328, #329)`.
---
-### Task 9: The purge sweep and the create-time clear
+### Task 9: The re-sign pass, on the door keys' loop
+
+**Files:**
+- Modify: `internal/console/apipolicy.go` (`resignAppPolicies`, `resignAppPolicy`)
+- Modify: `internal/console/apidoorkeys.go:346-365` (`StartDoorKeyResign` runs `s.resignAppPolicies(ctx)` after `s.resignDoorKeys(ctx)` on each pass; its doc comment names both)
+- Modify: `internal/console/claim_invariant_fixtures_test.go:1861` (the `worker StartDoorKeyResign` fixture's setup also PUTs a policy for `bab/titogo`, so the invariant sees the re-sign's write)
+- Test: `internal/console/apipolicy_resign_test.go`
+
+**Interfaces:**
+- Consumes: Task 3 (`Present`, `Key`, `GetForUpdate`, `PutIfMatchETag`, `ErrUnverifiable`), Task 8 (`policyLock`, `AppPolicyRevisionContext`, `RaiseAppPolicyRevisionContext`), `s.appMutation`, `AccountBySqid`, `doorKeyPublishBudget`.
+- Produces: `func (s *Server) resignAppPolicies(ctx context.Context)`; `func (s *Server) resignAppPolicy(ctx context.Context, acct *Account, app string) error`.
+
+Rules (the door keys' re-sign, `apidoorkeys.go:328-365`, with the bucket as the content's source):
+- Runs only where `StartDoorKeyResign` runs (a signing key and a bucket): at start, then every `doorKeyResignEvery` (12 h). A record lives `Lifetime` (72 h), so a console down for up to 60 h past its last pass leaves no policy expired; one down longer closes every granted address until it is back (spec §3, the door keys' trade), and its first pass re-signs them.
+- `Present` lists the keys; each must be exactly `Key(sqid, app)` with a valid account and app, else skipped. `AccountBySqid` missing → logged and skipped (the purge sweep owns it).
+- Per app, under `policyLock` and `s.appMutation(ctx, acct, app, "app-policy-resign", ...)`, within `doorKeyPublishBudget`: `GetForUpdate`. Absent → nothing. `ErrUnverifiable` → logged `console: app policy %s/%s: not re-signing an object that does not verify; an operator write repairs it`, nothing written (the console never signs bytes it cannot verify). With `h` from `AppPolicyRevisionContext`: revision below `h` → logged as replayed, nothing written (roots refuse it too, plan 2); revision above `h` → `RaiseAppPolicyRevisionContext(revision)` first. Then the same content at the **same revision** with `SignedAt = now`, `PutIfMatchETag(..., etag)` (replace-only, so a purge that deleted the key between the read and the write is never undone; `ErrCASConflict` and a not-found are both logged and skipped). The same revision is safe to re-sign: an older signing of it carries the same content with an earlier expiry, so replaying it gains nothing.
+- A failure for one app is logged and does not stop the others.
+
+- [ ] **Step 1: Write the failing tests** (`t.Parallel()`; the fixture's release key; records written with `PutIfMatch` directly)
+
+```go
+func TestResignRefreshesSignedAtAndKeepsTheRevision(t *testing.T) {
+ // a record at Revision 3 signed 50 h ago -> after resignAppPolicies: Verify ok at now+71h,
+ // Revision 3, content equal, SignedAt within the test's window
+}
+func TestResignRevivesAnExpiredRecord(t *testing.T) {
+ // signed 80 h ago (Verify -> ErrExpired) -> after the pass Verify ok
+}
+func TestResignLeavesUnverifiableAndReplayedObjectsAlone(t *testing.T) {
+ // app a: `{` at its key; app b: a Revision 1 record with the table at 4; app c: a valid record
+ // -> a's and b's objects byte-identical after the pass; c re-signed
+ // app d: a Revision 6 record with the table at 2 -> re-signed at 6, table raised to 6
+}
+func TestResignNeverRecreatesAPurgedPolicy(t *testing.T) {
+ // a store wrapper that deletes the key between GetForUpdate's Get and the Put -> the key is absent after the pass
+}
+func TestStartDoorKeyResignAlsoResignsPolicies(t *testing.T) {
+ // StartDoorKeyResign with a context cancelled after its first pass -> the policy's SignedAt moved
+ // a console with no signing key -> StartDoorKeyResign returns at once; the object is untouched
+}
+```
+
+- [ ] **Step 2: Run, fail:** `go test ./internal/console -run 'TestResign|TestStartDoorKeyResignAlsoResignsPolicies|TestClaimConsoleMutationInvariant'`.
+- [ ] **Step 3: Implement.** Comment on `resignAppPolicies`: why the content comes from the bucket (the object is the authority, spec §3) while the revision floor comes from the table (Task 8).
+- [ ] **Step 4: Run, pass.** Same command, plus `go test ./internal/console -run 'TestDoorKey'` (the door keys' own pass is unchanged).
+- [ ] **Step 5: Commit** `console: re-sign every app policy on the door keys' loop (#328, #329)`.
+
+---
+
+### Task 10: The purge sweep and the create-time clear
**Files:**
- Modify: `internal/release/trash.go:276-295` (append `AppPolicyKey(acct, app)` to `keys` before the delete loop, with a comment in the ledger paragraph's style)
@@ -592,7 +735,7 @@ func TestCreatingAnAppClearsAStalePolicy(t *testing.T) {
---
-### Task 10: CLI: `apps set --egress-allow/--size`, `instances set --size`, `apps show`
+### Task 11: CLI: `apps set --egress-allow/--size`, `instances set --size`, `apps show`
**Files:**
- Create: `apps_policy.go` (constants, client calls, `runAppsShow`)
@@ -609,7 +752,7 @@ func TestCreatingAnAppClearsAStalePolicy(t *testing.T) {
- `appsSetRun(ctx, sess, acct, app, swap *swapPolicyReq, policy updatePolicyReq, change *policyChange) error`; `instancesSetRun(ctx, sess, acct, app, host string, req map[string]any, channel *string, size *string) error` (order: settings, size, channel).
- `carlos apps show <app>`: operator view of the policy.
-Copy (Task 13), as named constants in `apps_policy.go` (the `TestAppsSetCopyIsNamedConstants` walker accepts identifiers):
+Copy (Task 14), as named constants in `apps_policy.go` (the `TestAppsSetCopyIsNamedConstants` walker accepts identifiers):
| const | text |
|---|---|
@@ -633,7 +776,7 @@ Copy (Task 13), as named constants in `apps_policy.go` (the `TestAppsSetCopyIsNa
| `appsShowNone` / `appsShowBuildSize` / `appsShowStale` | `none` / `the build's size` / ` (not in force. The instance was deleted or recreated)` |
| `appsShowDirectMode` | `apps show works through a console, and this shell is in direct bucket mode (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Log in instead: carlos auth login --console <url>` |
-`appsSetNothing` ("Pass --swap, --swap-concurrency, or both") gains the two flags: an edit to approved copy, listed in Task 13. `appsUsage` gains `show` and its line.
+`appsSetNothing` ("Pass --swap, --swap-concurrency, or both") gains the two flags: an edit to approved copy, listed in Task 14. `appsUsage` gains `show` and its line.
- [ ] **Step 1: Write the failing tests** (`apps_policy_test.go`, against an `httptest` console in the style of `TestAPISessionSetSwapPolicyWireShape`)
@@ -665,13 +808,13 @@ func TestAppsSetWithoutPolicyFlagsSendsNoPolicyRequest(t *testing.T) {
```
- [ ] **Step 2: Run, fail:** `go test . -run 'TestAppsSetPolicy|TestChangeAppPolicy|TestInstancesSetSize|TestAppsShow|TestAppsSetWithoutPolicy|TestAppsSet|TestAPISession'`.
-- [ ] **Step 3: Implement.** Direct bucket mode refuses (`appsSetDirectMode` for set, `appsShowDirectMode` for show). The CLI does not print the plan-3 build-tier warning or plan-2 `not checked` lines; plans 2 and 3 add them.
+- [ ] **Step 3: Implement.** Direct bucket mode refuses (`appsSetDirectMode` for set, `appsShowDirectMode` for show). The CLI prints no below-tier warning for `--size` (spec §5.2 rev 8 drops it, and no plan adds one). The `not checked` line from the reply's `not_checked` and the committed figures are plan 2's and plan 3's to add.
- [ ] **Step 4: Run, pass**, plus `go test . -run 'TestApprovedCopy|TestAppsSetCopyIsNamedConstants'`.
- [ ] **Step 5: Commit** `cli: apps set --egress-allow/--size, instances set --size, apps show (#328, #329)`.
---
-### Task 11: Nothing set is byte-identical (this plan's part)
+### Task 12: Nothing set is byte-identical (this plan's part)
**Files:**
- Test: `internal/provision/apppolicy_reconcile_test.go` (one test); no production change
@@ -707,14 +850,14 @@ Expected: all PASS. `TestReconcileMints` compares the whole minted route to a li
---
-### Task 12: Census overlay
+### Task 13: Census overlay
**Files:**
- Create: `internal/provision/testdata/plan14-root-reads-instance-policy-1.tsv`
- Modify: `internal/provision/plan14_root_reads_test.go` (after the `ci-speed-activator` load at line 1100: check its seal, load the new overlay, set the new seal)
- Create: `docs/superpowers/notes/census-proposal-instance-policy-1.md`
-- [ ] **Step 1: Run the census and read what it reports.** `go test ./internal/provision -run TestPlan14RootReadCensus`. Expected: FAIL naming the changed envelopes and units (at least `internal/registry`, `internal/provision`, `internal/release`, `internal/cgroup`, `internal/fleetapi`, `internal/hostops`) and any unit whose digest moved (`scanRoute`, `convergeOwned`, `purgeApp`, ...).
+- [ ] **Step 1: Run the census and read what it reports.** `go test ./internal/provision -run TestPlan14RootReadCensus`. Expected: FAIL naming the changed envelopes and units (at least `internal/apppolicy`, `internal/registry`, `internal/provision`, `internal/release`, `internal/cgroup`, `internal/fleetapi`, `internal/hostops`) and any unit whose digest moved (`scanRoute`, `convergeOwned`, `purgeApp`, ...).
- [ ] **Step 2: Write the overlay** in the shape of `plan14-root-reads-ci-speed-activator.tsv`: header comments stating that no root read is added (root reads neither column in this plan; plans 2 and 3 add those reads under their own overlays), each moved unit with its executing identity (the agent `carlos-edge.service` user `carlos` for reconcile; the console for the door, classifier and purge; boxes for the auto-purge) and disposition re-frozen at main's class and task, the envelopes re-pinned, and that the seal moves after ci-speed-activator's. Status `PROPOSED-instance-policy-1`.
- [ ] **Step 3: Write the proposal note** in the shape of `census-proposal-console-switcher-filter.md`: the branch, the base, the units table, the ruling asked of the census owner on #280.
- [ ] **Step 4: Run, pass:** `go test ./internal/provision -run 'TestPlan14RootReadCensus'`.
@@ -724,13 +867,13 @@ If another overlay lands on main first, load this one after it and say so in bot
---
-### Task 13: Strings for copy review, and the rollout note
+### Task 14: Strings for copy review, and the rollout note
**Files:**
- Modify: `README.md` under "Releasing the platform" (line 243): a dated entry, drafted for review, stating the rollout rule of spec §3 and §7 (every console that writes the bucket runs this version before the first policy is granted; a console downgraded below it must delete `control/app-policy/` first; with nothing granted, the release changes nothing)
- After review: `approved_instance_policy_copy_test.go` in the shape of `approved_run_copy_test.go`, pinning the approved strings
-- [ ] **Step 1: Collect every user-facing string** from: Task 1's error table; Task 8's door messages and audit detail parts (`egress allow: …`, `default size: …`, `size of …: …`, `none`, `the build's size`, `the app default`, ` (cli)`, the `app-policy-not-applied` row's detail); Task 10's table, the changed `appsSetNothing`, the `appsUsage` `show` line and `appsSetPolicyUsage`; the README entry. Count each string's occurrences at the base commit first (memory: shared strings need their own review).
+- [ ] **Step 1: Collect every user-facing string** from: Task 1's error table; Task 8's door messages and audit detail parts (`egress allow: …`, `default size: …`, `size of …: …`, `none`, `the build's size`, `the app default`, ` (cli)`, the `app-policy-not-applied` row's detail); Task 11's table, the changed `appsSetNothing`, the `appsUsage` `show` line and `appsSetPolicyUsage`; the README entry. Count each string's occurrences at the base commit first (memory: shared strings need their own review).
- [ ] **Step 2: Run the copy-review skill** with that list; wait for Paul's edits.
- [ ] **Step 3: Apply the edits verbatim**, rerun `go test . -run 'TestApprovedCopy|TestAppsSet|TestAppsShow|TestChangeAppPolicy'`, `go test ./internal/egressaddr`, `go test ./internal/console -run TestPolicy`.
- [ ] **Step 4: Add the approved-copy test** pinning the reviewed strings; run it.
@@ -740,6 +883,6 @@ If another overlay lands on main first, load this one after it and say so in bot
## Self-review
-- **Spec coverage (plan 1's share):** §3 object, CAS, `CreatedAt` binding, `app_id` stamp under claims, purge sweep before the marker, create-time clear, tidy removal (done at the next operator write instead of at instance delete, so no member door writes the object), precondition (recorded above, Task 6) → Tasks 3, 6, 8, 9. §4.1 address rules → Task 1. §4.3 before-claims delivery and Transport → Tasks 5, 7. §5.1 names → Task 2. §5.2 resolution of the configured part → Tasks 3, 7. §6 door, roles, mandatory audit → Task 8. §2 CLI goals → Task 10. §7 rollout note → Task 13. "Nothing set" → Task 11. Not here by design: §4.2 checks (plan 2, stub in Task 8), root's reads and writes, read-back and display columns (plans 2, 3), §5.3-5.6 (plan 3).
-- **Names:** the brief's pinned names are used unchanged; additions are listed under "Interfaces produced". `apppolicy.Prefix`/`Key` are defined through `release` (cycle), with the same values.
+- **Spec coverage (plan 1's share):** §3 signed record (domain, revision one above the current, 72 h lifetime, re-sign on the door keys' loop, the agent verifying before converging) → Tasks 3, 7, 8, 9; sidecar members get no policy (§4.3 rev 8) → Task 7; no below-tier warning (§5.2 rev 8) → Task 11 says so. §3 object, CAS, `CreatedAt` binding, `app_id` stamp under claims, purge sweep before the marker, create-time clear, tidy removal (done at the next operator write instead of at instance delete, so no member door writes the object), precondition (recorded above, Task 6) → Tasks 3, 6, 8, 9, 10. §4.1 address rules → Task 1. §4.3 before-claims delivery and Transport → Tasks 5, 7. §5.1 names → Task 2. §5.2 resolution of the configured part → Tasks 3, 7. §6 door, roles, mandatory audit → Task 8. §2 CLI goals → Task 11. §7 rollout note → Task 14. "Nothing set" → Task 12. Not here by design: §4.2 checks (plan 2, stub in Task 8), root's reads and writes, read-back and display columns (plans 2, 3), §5.3-5.6 (plan 3).
+- **Names:** the brief's pinned names (rev 8: `Get`, `PutIfMatch` and `Policy` changed for the signature, and the brief was updated with them) are used unchanged; additions are listed under "Interfaces produced". `apppolicy.Prefix`/`Key` are defined through `release` (cycle), with the same values.
- **Decisions the spec did not pin:** see the report that accompanies this plan.
diff --git a/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md b/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md
index 942629eb1..1e4a5c77d 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md
@@ -4,11 +4,11 @@
**Goal:** Root writes an operator-granted `IPAddressAllow=` onto each isolated instance's per-host slice, keeps it equal to policy by read-back, and the fleet can see it in force; the console checks fleet addresses at the grant and reports them afterwards.
-**Architecture:** One root object, `instancectl.EgressAsserter`, owns the slice's allow: it takes a desired list from a source (the registry column before claims, the store under claims), validates it with `egressaddr` and the box's own addresses, compares it with `systemctl show`, and writes only on a difference. Every start path calls it, a 30 s loop and a `POST /egress` nudge re-assert it, and `GET /egress` reports it to the agent, which carries it in the status snapshot. Boxes publish their addresses in the edges file; the console checks a grant against them and re-checks on a timer.
+**Architecture:** One root object, `instancectl.EgressAsserter`, owns the slice's allow: it takes a desired list from a source (the registry column before claims; under claims the signed policy root reads from the store itself, verifies against the release key, binds to the app's incarnation and holds to a per-app revision floor in root's own state), validates it with `egressaddr` and the box's own addresses, compares it with `systemctl show`, and writes only on a difference. Every start path calls it, a 30 s loop and a `POST /egress` nudge re-assert it, and `GET /egress` reports it to the agent, which carries it in the status snapshot. Under claims the loop reads each app's policy at most every 30 s, conditional on its ETag. Boxes publish their addresses in the edges file; the console checks a grant against them and re-checks on a timer.
**Tech Stack:** Go (`net/netip`, `os/exec` systemctl), systemd cgroup BPF firewall, cgroup v2, the deployment store (`release.Store`).
-**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 7, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`. Read §0, §4, §9 and §11 before any task.
+**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 8, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`. Read §0, §4, §9 and §11 before any task.
## Global Constraints
@@ -20,12 +20,13 @@
- Production-wired witnesses: root and claim behaviour is tested through the real control service, installed templates and wiring, never sleeps or stand-in units, except where a task says a fake `Systemctl` is the unit-level test.
- Nothing from a request reaches systemctl as a string (`server.go:30-35`). `POST /egress` carries only a host, validated by `provision.ValidHostShape`; every address root writes comes from the source.
- Nothing is on by default: with no policy object (and no `egress_allow` column value), root writes nothing, and every unit, slice and probe property is byte-identical to main. Root writes only when read-back differs from desired.
-- Consume plan 1 only by the brief's pinned names: `apppolicy.{Prefix,Key,Policy,Get}`, `egressaddr.{Parse,Allowed,FenceDeny}`, `registry.Route.EgressAllow`, the door `PUT /api/cli/apps/{account}/{app}/policy` and its `not_checked` reply field.
+- Consume plan 1 only by the names the brief and plan 1's "Interfaces produced" pin: `apppolicy.{Prefix,Key,Policy,Get,PutIfMatch,Verify,VerifyKey,ErrUnverifiable,ErrExpired,Lifetime}` and `(Policy).Expired`, `egressaddr.{Parse,Allowed,FenceDeny}`, `registry.Route.EgressAllow`, `provision.PolicyReadStates`, `release.ErrRefused`, the door `PUT /api/cli/apps/{account}/{app}/policy`, its `checkEgressGrant(ctx, acct, app, old, next []string) (notChecked []string, status int, msg string)` stub and its `not_checked` reply field.
- `apppolicy.Get` must return `os.ErrNotExist` only for a real absence. Over the fleet store door a refusal must surface as a different error (plan 1's transport work, spec §4.3). Task 6 tests the contract and refuses to build on it if plan 1 did not deliver it.
- User-facing strings (CLI output, door error messages, alert text, report words) are drafts until Task 17's copy review. Plain English, no em dashes. Internal error fragments and JSON keys are not copy.
- No tenant names: `shop-a.example.com`, `10.0.5.20`, `fd00:5::20`.
- Commit per task, ending with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
-- Plan 3 (sizes) touches the same start paths. Plan 2's egress call sits **immediately after the tier step and before `systemctl start`** in each path; plan 3 changes the tier step itself. Plan 3's `mem_total_mib` edges field sits beside `addrs` in the write gate (Task 8). Plan 3's `SIZE` column goes after `EGRESS` (Task 12).
+- Plan 3 (sizes) is built after this plan and touches the same start paths (`handleStart` for run and sidecar, `handleSeed`, `startGen`, `handleCmd`, `ClaimUnits.start`). Plan 2's egress call sits **immediately after the tier step and before `systemctl start`** in each. Plan 3 leaves those bodies and this call alone. In the same files it changes `parseRequest`'s tier check, `sliceTier`'s implementation (identical output), `claimTier`, `removeGenFiles`, two routes beside `/egress` in `Handler`, and one line in `startGen` (a control-drop-in removal before `writeGenDropIn`, above the tier block and this call). Plan 3's `mem_total_mib` edges field sits beside `addrs` in the write gate (Task 8). Plan 3's `SIZE` column goes before `EGRESS` (`BOX HOST LAYOUT SIZE EGRESS REASON`, Task 12).
+- Sidecar members (`Kind "sidecar"` rows) get no policy (plan 1) and any such row makes the box not wholly nested (Task 5), spec §4.3 rev 8.
## Review Focus
@@ -48,6 +49,8 @@
| `internal/instancectl/server.go`, `gen.go`, `cmd.go`, `claimunit.go`, `client.go` | Start-path calls, routes, client verbs. |
| `internal/instancectl/localaddrs.go` | Export the box address reader for the agent. |
| `internal/host/egress_source.go` (new) | Pre-claim and claims sources, the wholly-nested rail. |
+| `internal/host/egress_floor.go` (new) | Root's per-app revision floor under `/opt/carlos/system/guard/app-policy/`. |
+| `internal/release/conditional_get.go` (new) | `GetIfNoneMatch` and `ErrNotModified` for the per-app conditional read. |
| `internal/host/egress_nudge.go` (new) | Agent: `POST /egress` after a column change. |
| `internal/host/edgesfile.go`, `internal/edgesfile/addrs.go` (new) | `addrs` publication and the console's reader. |
| `internal/console/egressfleet.go` (new) | Grant-time check, report loop, alert, conflict set. |
@@ -114,13 +117,18 @@ type EgressPolicy struct {
Allow []string // entries as stored (registry column split on spaces, or Policy.EgressAllow)
Closed string // non-empty: write no allow; becomes the report's State
}
-// EgressSource reads one host's policy. A non-nil error means "could not
-// read it now": the asserter keeps the last policy it read for that host.
-type EgressSource func(ctx context.Context, host string) (EgressPolicy, error)
+// EgressSource reads one host's policy. fresh is true on a start path and on
+// POST /egress (read the store now); false from the sweep (a source may
+// answer from its own per-app cache, Task 6). A non-nil error means "could
+// not read it now": the asserter keeps the last policy it read for that host.
+type EgressSource func(ctx context.Context, host string, fresh bool) (EgressPolicy, error)
const (
EgressFailing = "failing" // desired and held differ after a write
EgressUnreadable = "unreadable" // the policy could not be read or was refused
+ EgressUnverifiable = "unverifiable" // the record's signature, shape or key binding failed (spec §3 rev 8)
+ EgressExpired = "expired" // signed more than apppolicy.Lifetime ago
+ EgressReplayed = "replayed" // revision below root's floor for the app
EgressOtherIncarnation = "other_incarnation" // policy app_id is not the current app's
EgressNotNested = "not_nested" // pre-claim box not wholly nested
)
@@ -137,14 +145,14 @@ type EgressAsserter struct {
Systemctl func(context.Context, ...string) (string, error)
Source EgressSource
LocalAddrs func() ([]netip.Prefix, error)
- CgroupRoot string // Task 4's sweep
+ CgroupRoot string // Task 4's sweep
Hosts func() ([]string, error) // Task 4: names for slices at start
- Refresh time.Duration // Task 4: 0 = read the source on every sweep
Logf func(string, ...any)
- // unexported: mu, last map[host]EgressPolicy, lastRead map[host]time.Time,
+ // unexported: mu, last map[host]EgressPolicy,
// reports map[host]EgressReport, names map[iid]host
}
-func (e *EgressAsserter) Assert(ctx context.Context, host string) error
+func (e *EgressAsserter) Assert(ctx context.Context, host string) error // fresh: start paths and POST /egress
+func (e *EgressAsserter) assert(ctx context.Context, host string, fresh bool) error // Sweep passes false
func (e *EgressAsserter) Reports() map[string]EgressReport // a copy
func LocalAddresses() ([]netip.Prefix, error) // localPrefixes(fibTriePath, ifInet6Path)
```
@@ -158,14 +166,15 @@ A nil `*EgressAsserter` is valid: `Assert` returns nil and does nothing, `Report
- `TestEgressDropsTheBoxsOwnAddress`: `LocalAddrs` returns `10.0.5.20/32`; written only `10.0.5.21/32`; `Refused: ["10.0.5.20: an address of this box (10.0.5.20/32)"]`.
- `TestEgressClosesWhenTheBoxsAddressesCannotBeRead`: `LocalAddrs` errors; written the reset alone; every entry refused with `this box's addresses could not be read`.
- `TestEgressMoreThanEightClosesAll`: nine valid entries; reset alone; `State: EgressUnreadable`, `Detail` says more than 8.
- - `TestEgressClosedPolicyResets`: `Closed: EgressOtherIncarnation` with `Allow` set and the slice holding `10.0.5.20/32`; reset written; `State: EgressOtherIncarnation`.
+ - `TestEgressClosedPolicyResets`: for each of `EgressOtherIncarnation`, `EgressUnverifiable`, `EgressExpired`, `EgressReplayed`: `Closed` set with `Allow` set and the slice holding `10.0.5.20/32`; reset written; `State` is that word.
+ - `TestEgressAssertReadsFresh`: `Assert` calls the source with `fresh == true`.
- `TestEgressReadBackFormsAreOneSet` (Review Focus 1): desired `10.0.5.20, fd00:5::20`; show answers `fd00:5:0:0:0:0:0:20/128 10.0.5.20`; no `set-property` call.
- `TestEgressFailingAfterAWriteThatDoesNotHold`: fake ignores the write; `Assert` returns an error; `State: EgressFailing`; `Allowed` is what show returned.
- `TestEgressSourceErrorKeepsTheLastPolicy`: first `Assert` with `Allow: {"10.0.5.20"}`, second with a source error; the second writes nothing (held matches the cached policy); `State: EgressUnreadable`, `Allowed: ["10.0.5.20"]`.
- `TestEgressNoCacheAndSourceErrorLeavesTheSliceAlone` (Review Focus 2): fresh asserter, source errors, slice shows `10.0.5.20/32`; no `set-property`; `State: EgressUnreadable`, `Allowed: ["10.0.5.20"]`.
- `TestEgressNilAsserterIsANoOp`.
- [ ] **Step 2: Run** `TMPDIR=/tmp/g go test ./internal/instancectl -run 'TestEgress' -count=1`. Expected: FAIL, `EgressAsserter` undefined.
-- [ ] **Step 3: Implement** in `egress.go`. `Assert`: read source (error → cached policy, or leave the slice alone if none, `State: EgressUnreadable`); `Closed` → want empty; else per entry `egressaddr.Parse` then `egressaddr.Allowed` then `isLocal` against `LocalAddrs()`; more than 8 accepted → want empty; want as `a/32` or `a/128`, sorted; read back with `show -p IPAddressAllow --value <slice>`, parse into a set of masked prefixes (bare address = full-length prefix; an unparseable token makes the sets unequal); equal → no write; else one `set-property --runtime <slice> IPAddressAllow= [IPAddressAllow=<want...>]`, read back again, unequal → `EgressFailing` and an error. `Allowed` is the read-back as bare addresses (Task 1 Step 5 decides whether the parent's entries are subtracted). Apply Task 1 Step 6's branches if its verdict requires them. Put Task 1's verdict comment at the top of the file.
+- [ ] **Step 3: Implement** in `egress.go`. `Assert` is `assert(ctx, host, true)`. `assert`: read source (error → cached policy, or leave the slice alone if none, `State: EgressUnreadable`); `Closed` → want empty; else per entry `egressaddr.Parse` then `egressaddr.Allowed` then `isLocal` against `LocalAddrs()`; more than 8 accepted → want empty; want as `a/32` or `a/128`, sorted; read back with `show -p IPAddressAllow --value <slice>`, parse into a set of masked prefixes (bare address = full-length prefix; an unparseable token makes the sets unequal); equal → no write; else one `set-property --runtime <slice> IPAddressAllow= [IPAddressAllow=<want...>]`, read back again, unequal → `EgressFailing` and an error. `Allowed` is the read-back as bare addresses (Task 1 Step 5 decides whether the parent's entries are subtracted). Apply Task 1 Step 6's branches if its verdict requires them. Put Task 1's verdict comment at the top of the file.
- [ ] **Step 4: Run** the Step 2 command. Expected: PASS.
- [ ] **Step 5: Cross-compile** `GOOS=darwin go vet ./internal/instancectl && GOOS=windows go vet ./internal/instancectl`. Expected: clean.
- [ ] **Step 6: Commit** `egress.go`, `egress_test.go`, `localaddrs.go`: `instancectl: the egress asserter (validate, read back, write on difference) (#328)`.
@@ -217,11 +226,11 @@ func RunWithRegistry(args []string, hostApp func(string) (string, string, error)
func RunWithClaim(args []string, handler, heldRelease http.Handler, hostApp func(string) (string, string, error), egress *EgressAsserter)
```
-Sweep: for each populated slice, name it (`Unescape` of the part between `carlos-instances-` and `.slice` gives the instance id; a name `Assert` or `Hosts` recorded for that id gives the host; an id not starting with `_` is the host itself); then `Assert` with the cached policy when it is younger than `Refresh`, else a fresh source read. A slice it cannot name gets `IPAddressAllow=` (reset only, and only when it holds something), logged as `egress: <slice>: no host for this slice; its allow is closed`. `POST /egress` and every start path always read the source fresh. `Loop` seeds names from `Hosts()` once at start (an error is logged, not fatal), sweeps at once, then every `every`; production `every` is 30 s (`DiskEvery`'s value). A host failing three sweeps in a row is logged every sweep while it fails.
+Sweep: for each populated slice, name it (`Unescape` of the part between `carlos-instances-` and `.slice` gives the instance id; a name `Assert` or `Hosts` recorded for that id gives the host; an id not starting with `_` is the host itself); then `assert(ctx, host, false)`. The asserter keeps no read cadence of its own: a source decides what `fresh == false` costs (the registry source reads the local file every time; the claims source answers from its per-app cache, Task 6). A slice it cannot name gets `IPAddressAllow=` (reset only, and only when it holds something), logged as `egress: <slice>: no host for this slice; its allow is closed`. `POST /egress` and every start path call `Assert` (`fresh == true`). `Loop` seeds names from `Hosts()` once at start (an error is logged, not fatal), sweeps at once, then every `every`; production `every` is 30 s (`DiskEvery`'s value). A host failing three sweeps in a row is logged every sweep while it fails.
-- [ ] **Step 1: Write the failing tests:** `TestEgressSweepAssertsEveryPopulatedSlice` (fixture cgroup tree as in `server_test.go`'s `CgroupRoot`; two populated, one `populated 0`; only the two are read back); `TestEgressSweepResetsAnUnattributableSlice` (Review Focus 3: `_shop-a-long...-<hash>` slice, no name known, holds `10.0.5.20/32` → reset alone); `TestEgressSweepUsesTheCacheUntilRefresh` (source called once across two sweeps with `Refresh: time.Hour`, twice with `Refresh: 0`); `TestEgressRouteAssertsFresh` and `TestEgressRouteRefusesABadHost` (via `newServer`); `TestEgressRoutesServedWithClaimsOn` (`ClaimStop` set: `GET /egress` 200, `POST /start` still 503); `TestClientEgressVerbs` in `client_test.go`.
+- [ ] **Step 1: Write the failing tests:** `TestEgressSweepAssertsEveryPopulatedSlice` (fixture cgroup tree as in `server_test.go`'s `CgroupRoot`; two populated, one `populated 0`; only the two are read back); `TestEgressSweepResetsAnUnattributableSlice` (Review Focus 3: `_shop-a-long...-<hash>` slice, no name known, holds `10.0.5.20/32` → reset alone); `TestEgressSweepReadsWithFreshFalse` (the source sees `fresh == false` for every swept host, and `true` from `POST /egress`); `TestEgressRouteAssertsFresh` and `TestEgressRouteRefusesABadHost` (via `newServer`); `TestEgressRoutesServedWithClaimsOn` (`ClaimStop` set: `GET /egress` 200, `POST /start` still 503); `TestClientEgressVerbs` in `client_test.go`.
- [ ] **Step 2: Run** `TMPDIR=/tmp/g go test ./internal/instancectl -run 'TestEgressSweep|TestEgressRoute|TestClientEgress' -count=1`. Expected: FAIL.
-- [ ] **Step 3: Implement**; update `internal/host/claimstop_run.go:36,87` to pass `nil` for now (Task 5 and 6 wire them).
+- [ ] **Step 3: Implement**; update `internal/host/claimstop_run.go:36,87` to pass `nil` for now (Tasks 5 and 6 wire them).
- [ ] **Step 4: Run** Step 2's command, then `go build ./... && GOOS=windows go build ./... && GOOS=darwin go build ./...`. Expected: PASS, builds.
- [ ] **Step 5: Commit** `instancectl: re-assert egress every 30 s and on POST /egress; report on GET /egress (#328)`.
@@ -238,56 +247,102 @@ Sweep: for each populated slice, name it (`Unescape` of the part between `carlos
- Produces:
```go
-// boxWhollyNested: every row the exec backend could run as a child of the
-// agent (activator.ActivatorManaged, and every IsSidecar row) classifies
-// LayoutNested under Classify with the layout journal; reason names the
-// first row that does not.
+// boxWhollyNested: no row is a sidecar member (IsSidecar, Kind "sidecar":
+// outside the layout pass and run flat, spec §4.3 rev 8), and every
+// activator.ActivatorManaged row classifies LayoutNested under Classify with
+// the layout journal; reason names the first row that fails.
func boxWhollyNested(rows []registry.Route, dataDir string) (ok bool, reason string)
-func registryEgressSource(dataDir string) instancectl.EgressSource
+func registryEgressSource(dataDir string) instancectl.EgressSource // ignores fresh: the registry is a local file
func registryEgressHosts(dataDir string) func() ([]string, error)
-func newEgressAsserter(src instancectl.EgressSource, hosts func() ([]string, error), refresh time.Duration) *instancectl.EgressAsserter // production Systemctl, LocalAddresses, /sys/fs/cgroup, log.Printf
+func newEgressAsserter(src instancectl.EgressSource, hosts func() ([]string, error)) *instancectl.EgressAsserter // production Systemctl, LocalAddresses, /sys/fs/cgroup, log.Printf
```
`registryEgressSource` opens the registry read-only per call, lists rows once, applies `boxWhollyNested` (not → `Closed: EgressNotNested`, `Detail` the reason; the source **writes nothing to the registry or the layout tree**), and returns the host's `EgressAllow` split on spaces. No row for the host → `EgressPolicy{}`. A registry open or list error is returned as an error (keep last).
-Decision (spec silent): a `Kind "sidecar"` row is outside the layout pass (`layoutleg.go:195-200`), so it counts as not nested unless `Classify` calls it nested. Fails closed.
+Spec §4.3 rev 8: a `Kind "sidecar"` row is outside the layout pass (`layoutleg.go:195-200`) and runs flat, so any such row makes the box not wholly nested, whatever `Classify` would say; plan 1 gives sidecar members no policy, so their own columns are empty either way.
-- [ ] **Step 1: Write the failing tests:** `TestBoxWhollyNestedTable` (all nested; one flat; one fenced by a journal file in a temp data dir; one foreign; a sidecar row; an external unflagged socket row, which does not count); `TestRegistryEgressSourceReadsTheColumn` (temp registry via the package's existing registry test helper, row `EgressAllow: "10.0.5.20 fd00:5::20"`); `TestRegistryEgressSourceClosesOnAMixedBox`; `TestRegistryEgressSourceWritesNothing` (registry file and `layout/` tree byte-identical and mtimes unchanged after a call).
+- [ ] **Step 1: Write the failing tests:** `TestBoxWhollyNestedTable` (all nested; one flat; one fenced by a journal file in a temp data dir; one foreign; a sidecar member row, which alone makes it false with the reason naming it; an external unflagged socket row, which does not count); `TestRegistryEgressSourceReadsTheColumn` (temp registry via the package's existing registry test helper, row `EgressAllow: "10.0.5.20 fd00:5::20"`); `TestRegistryEgressSourceClosesOnAMixedBox`; `TestRegistryEgressSourceWritesNothing` (registry file and `layout/` tree byte-identical and mtimes unchanged after a call).
- [ ] **Step 2: Run** `go test ./internal/host -run 'TestBoxWhollyNested|TestRegistryEgressSource' -count=1`. Expected: FAIL.
-- [ ] **Step 3: Implement**, and in `RunInstanceControl` pass `newEgressAsserter(registryEgressSource(dataDir()), registryEgressHosts(dataDir()), 0)` to `RunWithRegistry`.
+- [ ] **Step 3: Implement**, and in `RunInstanceControl` pass `newEgressAsserter(registryEgressSource(dataDir()), registryEgressHosts(dataDir()))` to `RunWithRegistry`.
- [ ] **Step 4: Run** Step 2's command. Expected: PASS.
- [ ] **Step 5: Commit** `host: the pre-claim egress source and the wholly-nested rail (#328)`.
---
-### Task 6: Claims source with the `app_id` binding, and the claim launch wiring
+### Task 6: Claims source: the signed policy, the revision floor, the per-app conditional read, the `app_id` binding, and the claim launch wiring
**Files:**
+- Create: `internal/release/conditional_get.go`, `internal/release/conditional_get_test.go`
+- Modify: `internal/release/store.go` (`S3Store` and `DirStore` implement `ConditionalGetter`)
+- Create: `internal/host/egress_floor.go`, `internal/host/egress_floor_test.go`
- Modify: `internal/host/egress_source.go`, `egress_source_test.go`, `claimstop_run.go` (claims branch 38-88), `claimconstruction.go:40-45` (`ClaimRuntimeResources` gains `Egress *instancectl.EgressAsserter`), `claimunit_production.go:34-...` (`bindProductionClaimUnits`: set `base.Egress` from the runtime resources before `base` is wrapped)
**Interfaces:**
-- Consumes: `rootInstallationHostApp` (`rootinstallation.go:11-45`); `apppolicy.Get`, `(apppolicy.Policy).Validate` (plan 1); `hostops.ReadCanonicalAdmission` (`internal/hostops/admission.go:130-150`).
+- Consumes: `rootInstallationHostApp` (`rootinstallation.go:11-45`); `apppolicy.Key`, `VerifyKey`, `ErrUnverifiable`, `ErrExpired`, `(Policy).Expired`, `release.ErrRefused` (plan 1); `hostops.ReadCanonicalAdmission` (`internal/hostops/admission.go:130-150`); `release.ParsePublicKey` on `CARLOS_RELEASE_PUBKEY` (as `readProductionRosterRow`, `claimproduction.go:84`); `rootstate.Open` and `systemGuardDir` (`systemparticipation.go:27`).
- Produces:
```go
-func storeEgressSource(hostApp func(string) (string, string, error), store release.Store) instancectl.EgressSource
-const claimEgressRefresh = 5 * time.Minute
+// internal/release
+var ErrNotModified = errors.New("store: not modified")
+type ConditionalGetter interface {
+ GetIfNoneMatch(ctx context.Context, key, etag string) ([]byte, string, error) // ErrNotModified when the object's etag is etag
+}
+// GetIfNoneMatch: etag "" is a plain Get; a store that is not a ConditionalGetter
+// (fleetapi, test fakes, wrappers) falls back to Get and compares the etag itself.
+func GetIfNoneMatch(ctx context.Context, s Store, key, etag string) ([]byte, string, error)
+
+// internal/host
+func appPolicyFloorDir(opt string) string // filepath.Join(systemGuardDir(opt), "app-policy")
+type policyFloors interface { // rootstate.Store satisfies it
+ Read(name string) ([]byte, error)
+ Write(name string, body []byte) error
+}
+type appPolicyFloor struct { AppID string `json:"app_id"`; Revision uint64 `json:"revision"` } // file "<sqid>.<app>.json"
+func newStoreEgressSource(hostApp func(string) (string, string, error), store release.Store,
+ pub ed25519.PublicKey, floors policyFloors, now func() time.Time) instancectl.EgressSource
+const claimPolicyEvery = 30 * time.Second
```
-Rules, in order: `hostApp(host)`: `os.ErrPermission` or `os.ErrNotExist` → `Closed: EgressUnreadable`; other error → error. `apppolicy.Get(ctx, store, acct, app)`: `os.ErrNotExist` → `EgressPolicy{}`; `os.ErrPermission` or any refusal plan 1's transport distinguishes → `Closed: EgressUnreadable`; other error → error. `Validate()` fails → `Closed: EgressUnreadable` with the reason. `ReadCanonicalAdmission(ctx, store, acct, app)`: `os.ErrNotExist` or `hostops.ErrIdentity` → `Closed: EgressOtherIncarnation`; other error → error; `policy.AppID == "" || policy.AppID != adm.Value.AppID` → `Closed: EgressOtherIncarnation`. Otherwise `Allow: policy.EgressAllow`. Nothing the agent wrote is read: no registry column, no file under `/data`.
+The source keeps, under one mutex, per host the `(acct, app)` `hostApp` last answered, and per app the last read: ETag, the verified policy (or "absent"), the admission's `app_id`, and when it was read.
+
+Rules, in order, for `source(ctx, host, fresh)`:
+1. **Which app.** `fresh`, or no cached answer for the host: `hostApp(host)`; `os.ErrPermission` or `os.ErrNotExist` → `Closed: EgressUnreadable`; other error → error. Otherwise the cached `(acct, app)`.
+2. **Whether to read.** Read the store when `fresh`, or the app has no cached read, or its cached read found a policy (any content) and is at least `claimPolicyEvery` old. An app whose last read found no object is read again on a start path, on `POST /egress`, or once `claimPolicyAbsentEvery` (5 minutes) has passed, so the loop costs one conditional request per app with a policy per 30 s and one per app without one per 5 minutes (spec §3 rev 8). A first grant to an app whose instances are already running reaches them through the agent's `POST /egress` (Task 7) at once, and within 5 minutes whatever the agent does; a change or revoke of an existing policy reaches them within 30 s whatever the agent does. Test: `TestClaimPolicyAbsentAppIsReadAgainAfterFiveMinutes` (fake clock: no read at 4m59s, one read at 5m).
+3. **The read.** `release.GetIfNoneMatch(ctx, store, apppolicy.Key(acct, app), cachedETag)`: `ErrNotModified` → keep the cached record, stamp the read time; `os.ErrNotExist` → no policy (`EgressPolicy{}`), cache "absent"; `release.ErrRefused` or `os.ErrPermission` → `Closed: EgressUnreadable` (a refusal is never absence, spec §4.3); other error → `Closed: EgressExpired` if the cached policy has expired, else error (the asserter keeps the last policy, as today, so a store outage closes nothing until the record it last verified expires).
+4. **Verify.** `apppolicy.VerifyKey(pub, wire, now(), acct, app)`: `ErrExpired` → `Closed: EgressExpired`; any other error → `Closed: EgressUnverifiable` with the reason in `Detail`. Nothing unverified is cached as a policy.
+5. **Incarnation.** On a fresh read (and on the first read of an app), `ReadCanonicalAdmission(ctx, store, acct, app)`: `os.ErrNotExist` or `hostops.ErrIdentity` → `Closed: EgressOtherIncarnation`; other error → error. `policy.AppID == "" || policy.AppID != adm.Value.AppID` → `Closed: EgressOtherIncarnation`. The sweep reuses the admission's `app_id` from the last fresh read: a purge and recreate means new claims and new starts, which are fresh.
+6. **Revision floor** (after step 5, so a policy for another incarnation can never move it). Read `<sqid>.<app>.json` from `floors`: absent → none. A floor for the same `app_id` with `Revision` above the policy's → `Closed: EgressReplayed`, `Detail` `revision <n> is below <floor>` (an older signed record the agent put back). Otherwise, if there is no floor, or it names another `app_id` (a new incarnation starts its own floor), or the policy's revision is higher, write `{policy.AppID, policy.Revision}` **before** returning the allow. A floor read error other than not-exist, or a write error → `Closed: EgressUnreadable`, `Detail` naming the floor (root's own state failing is not a store blip, so it closes rather than keeping the last allow).
+7. **Expiry between reads.** Whenever the source answers from its cache, `cached.Expired(now())` → `Closed: EgressExpired`. A console down for longer than `apppolicy.Lifetime` therefore closes the address on every box within 30 s of the expiry, with no store read (spec §3, the door keys' trade).
+8. Otherwise `Allow: policy.EgressAllow`. Nothing the agent wrote is read: no registry column, no file under `/data`.
+
+The floor lives in root's own state directory, beside the moves and seeds floors (`rootmovesfloor.go:51`, `rootseedsfloor.go:16`): `rootstate.Open(appPolicyFloorDir(release.OptDir()), true, false)`, so `/opt/carlos/system/guard/app-policy/<sqid>.<app>.json`, root-owned and `0600`, written atomically by `rootstate`. It survives a control-service restart and a reboot; nothing removes it (a purged app's file is superseded by the next incarnation's `app_id`). An empty `app_id` never reaches step 6: step 5 closes it.
+
+Conditional reads: `S3Store.GetIfNoneMatch` sets `IfNoneMatch` on `GetObjectInput` and maps S3's 304 to `ErrNotModified`; `DirStore` compares its own ETag. A 304 is still a billed request, but it transfers nothing and decodes nothing.
+
+- [ ] **Step 1: Write the failing tests** (memory store, `hostApp` stub, a fresh `ed25519.GenerateKey` as the release key, records written with `apppolicy.PutIfMatch`, a `policyFloors` fake backed by a `t.TempDir()` map that persists across sources, an injected clock):
-Decisions (spec silent): a claims-mode policy with an empty `app_id` is "another incarnation" (fails closed; plan 1's door must stamp `app_id` whenever the console runs with host claims). The 30 s sweep reads the store at most every `claimEgressRefresh` per host (HostApp and the two GETs per host every 30 s would be the polling cost the October bill showed); start paths and `POST /egress` read fresh. A revoke under claims therefore reaches a running instance within 5 minutes even if the agent never nudges.
+`conditional_get_test.go`:
+ - `TestGetIfNoneMatchDirStore`: unchanged object with its etag → `ErrNotModified`; changed → body and new etag; `""` → plain Get; absent → `os.ErrNotExist`.
+ - `TestGetIfNoneMatchFallsBackForAPlainStore`: a `Store` wrapper hiding `ConditionalGetter` → same answers through `Get`.
+ - `TestS3StoreIsAConditionalGetter`: `var _ release.ConditionalGetter = (*release.S3Store)(nil)` and the same for `*DirStore`.
-- [ ] **Step 1: Write the failing tests** (memory store, `hostApp` stub):
+`egress_source_test.go`:
- `TestStoreEgressSourceReadsTheVerifiedApp` (policy at `apppolicy.Key(acct, app)` with matching `app_id`; also asserts `acct` from a record equals the sqid plan 1's door writes under, using the console's key for the same account).
+ - `TestStoreEgressSourceRefusesWhatDoesNotVerify` (table → `Closed`): an unsigned JSON at the key → `EgressUnverifiable`; signed by another key → `EgressUnverifiable`; the right key's record for app `other` copied to this app's key → `EgressUnverifiable`; signed `Lifetime + time.Minute` ago → `EgressExpired`.
+ - `TestStoreEgressSourceRefusesAReplayedRecord`: write rev 1 (allow `10.0.5.20`), read fresh (allowed, floor 1); write rev 2 (no allow), read fresh (floor 2); put rev 1's bytes back at the key; read fresh → `Closed: EgressReplayed` and the floor still 2.
+ - `TestStoreEgressFloorSurvivesARestart`: source A accepts rev 3; a new source B over the same `floors` (as after a control-service restart) given rev 2's bytes → `EgressReplayed`; given rev 4 → allowed and the floor file says 4.
+ - `TestStoreEgressFloorFollowsTheIncarnation`: floor `{app_id: "a1", revision: 9}`; admission now `a2`, policy `app_id: a2` at rev 1 → allowed, floor `{a2, 1}`; a rev 9 policy with `app_id: a1` → `EgressOtherIncarnation`, floor unchanged.
+ - `TestStoreEgressFloorFailureCloses`: `floors.Write` fails → `Closed: EgressUnreadable`.
- `TestStoreEgressSourceOtherIncarnation` (table: mismatched, empty, admission absent, admission `ErrIdentity`).
+ - `TestStoreEgressSourceReadsEachAppAtMostEvery30s`: two hosts of one app and one of another, the clock advanced 10 s per sweep (`fresh == false`): a counting store sees one conditional GET per app per 30 s of clock, none for the app whose object is absent, and `GetIfNoneMatch` carries the cached etag; `fresh == true` reads at once.
+ - `TestStoreEgressSourceExpiresFromTheCache`: a cached verified policy and the clock past `SignedAt + Lifetime`: a sweep within 30 s of the last read → `Closed: EgressExpired` with no store call; with the store then failing every read → still `EgressExpired`, never an error that would keep the allow.
- `TestStoreEgressSourceRefusalIsNotAbsence`: a store whose `Get` returns a refusal for the policy key → `Closed: EgressUnreadable`, never `EgressPolicy{}`. Run it also against plan 1's fleet-door client fixture (`internal/fleetapi`) answering the door's refusal; if that returns `os.ErrNotExist`, stop and report: plan 1's transport is not done.
- `TestStoreEgressSourceTransientErrorKeepsLast` (store `Get` fails with a network error → error returned).
- `TestStoreEgressSourceIgnoresTheRegistry` (registry row carries a different `EgressAllow`; result is the store's).
-- [ ] **Step 2: Run** `go test ./internal/host -run 'TestStoreEgressSource' -count=1`. Expected: FAIL.
-- [ ] **Step 3: Implement.** In `RunInstanceControl`'s claims branch build `newEgressAsserter(storeEgressSource(rootInstallationHostApp(entry.Assembly.Guard, entry.Assembly.Claims), entry.Assembly.Claims), registryEgressHosts(dataDir()), claimEgressRefresh)`, put it in `ClaimRuntimeResources.Egress` before `ensureProductionClaimAssembly`, and pass it to `RunWithClaim`. The registry is used there only to name slices; desired lists come from the store.
-- [ ] **Step 4: Run** Step 2's command and `go test ./internal/host -run 'TestClaimUnit|TestBindProductionClaimUnits' -count=1`. Expected: PASS.
-- [ ] **Step 5: Commit** `host: under claims root reads the egress policy from the store, bound to the app's incarnation (#328)`.
+- [ ] **Step 2: Run** `go test ./internal/release -run 'TestGetIfNoneMatch|TestS3StoreIsAConditionalGetter' -count=1 && go test ./internal/host -run 'TestStoreEgress' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Implement.** In `RunInstanceControl`'s claims branch: parse `CARLOS_RELEASE_PUBKEY` (a parse error is fatal there, as the roster read's is), open the floor directory, build `newEgressAsserter(newStoreEgressSource(rootInstallationHostApp(entry.Assembly.Guard, entry.Assembly.Claims), entry.Assembly.Claims, pub, floors, time.Now), registryEgressHosts(dataDir()))`, put it in `ClaimRuntimeResources.Egress` before `ensureProductionClaimAssembly`, and pass it to `RunWithClaim`. The registry is used there only to name slices; desired lists come from the store. Comment on `newStoreEgressSource`: why it verifies (the agent holds the box role and can write the key, spec §3 rev 8), why the floor is in root's state, why step 5 precedes step 6.
+- [ ] **Step 4: Run** Step 2's command and `go test ./internal/host -run 'TestClaimUnit|TestBindProductionClaimUnits' -count=1`. Expected: PASS. Cross-compile: `GOOS=darwin go build ./internal/host ./internal/release && GOOS=windows go build ./internal/host ./internal/release`.
+- [ ] **Step 5: Commit** `host, release: under claims root verifies the signed egress policy, holds a revision floor and reads each app at most every 30 s (#328)`.
---
@@ -298,10 +353,10 @@ Decisions (spec silent): a claims-mode policy with an empty `app_id` is "another
- Modify: `internal/host/agent.go:455` (start the loop where `eb.Control` is set)
**Interfaces:**
-- Consumes: `(*instancectl.Client).Egress` (Task 4); `(*registry.Registry).Fingerprint`, `List`.
+- Consumes: `(*instancectl.Client).Egress` (Task 4); `(*registry.Registry).SnapshotFingerprint`, `List`.
- Produces: `func egressNudgeLoop(ctx context.Context, reg *registry.Registry, c interface{ Egress(context.Context, string) error })` and its testable step `func (n *egressNudger) pass(ctx context.Context, rows []registry.Route)`.
-Polls `reg.Fingerprint()` every `edgesFileWatchInterval` (2 s); on a change lists rows and calls `Egress(host)` for each row whose `EgressAllow` differs from the value it last nudged (a host first seen with a non-empty value counts as changed). A failed call is logged (throttled as `edgesFileLoop` does) and retried on the next change or pass. This is promptness only: root's own loop is the guarantee, so the agent's crash loses nothing.
+Polls `reg.SnapshotFingerprint()` every `edgesFileWatchInterval` (2 s); `Fingerprint()` would never move, because plan 1 keeps both policy columns out of it and in `snapshotWhenSetCols`. On a change lists rows and calls `Egress(host)` for each row whose `EgressAllow` differs from the value it last nudged (a host first seen with a non-empty value counts as changed). A failed call is logged (throttled as `edgesFileLoop` does) and retried on the next change or pass. This is promptness only: root's own loop is the guarantee, so the agent's crash loses nothing.
- [ ] **Step 1: Failing tests:** `TestEgressNudgeOnlyChangedHosts`, `TestEgressNudgeRevokeToEmpty`, `TestEgressNudgeRetriesAfterAFailure`.
- [ ] **Step 2: Run** `go test ./internal/host -run 'TestEgressNudge' -count=1`. Expected: FAIL.
@@ -352,7 +407,7 @@ func (inv AddrInventory) Holder(a netip.Addr) (box string, ok bool)
**Files:**
- Create: `internal/console/egressfleet.go`, `egressfleet_test.go`
-- Modify: plan 1's policy door handler for `PUT /api/cli/apps/{account}/{app}/policy` (find it by `apppolicy.PutIfMatch` in `internal/console`): call the check after its validation and before its audit and write.
+- Modify: `internal/console/apipolicy.go` (plan 1): fill the body of plan 1's stub `checkEgressGrant(ctx, acct, app, old, next []string) (notChecked []string, status int, msg string)`. The door already calls it after its validation and before its audit and write, whenever the request carries `egress_allow`.
**Interfaces:**
- Consumes: `edgesfile.ReadAddrs`, `AddrInventory.Holder` (Task 8); `s.systemBoxes(ctx, false)` (`apisystemupdate.go:269`); `status.BoxStale`; `doorRefusal` (`domainattach.go:74`).
@@ -365,7 +420,7 @@ func (inv AddrInventory) Holder(a netip.Addr) (box string, ok bool)
func (s *Server) egressGrantCheck(ctx context.Context, added []netip.Addr) (notChecked []string, ref *doorRefusal)
```
-Refusals (draft copy, Task 17): an added address a box lists → 409 `10.0.5.20 is an address of box <box>. An egress allow cannot open a box of this fleet.`; any listed file unreadable → 503 `Box <box>'s address list could not be read. Try again in a minute.`; any box `Missing` → 409 `Box <box> does not publish its addresses yet. Upgrade it before granting an egress allow.`; listing failed → 503. Only additions are checked, so a revoke always goes through (decision, spec silent). The door's reply sets `not_checked` to `notChecked` (empty list, not null, when none).
+`checkEgressGrant` parses `next` minus `old` into `added` and calls `egressGrantCheck`; a non-nil `ref` becomes `ref.Status, ref.Msg`, else `notChecked, 0, ""`. Refusals (draft copy, Task 17): an added address a box lists → 409 `10.0.5.20 is an address of box <box>. An egress allow cannot open a box of this fleet.`; any listed file unreadable → 503 `Box <box>'s address list could not be read. Try again in a minute.`; any box `Missing` → 409 `Box <box> does not publish its addresses yet. Upgrade it before granting an egress allow.`; listing failed → 503. Only additions are checked, so a revoke always goes through (decision, spec silent). The door's reply sets `not_checked` to `notChecked` (empty list, not null, when none).
- [ ] **Step 1: Failing tests** (`t.Parallel()`; memory store with edges files and status boxes): `TestEgressGrantRefusesAFleetAddress` (including one listed only in an old edges file whose status is stale), `TestEgressGrantRefusesWhileAFileIsUnreadable`, `TestEgressGrantRefusesAnOlderBox`, `TestEgressGrantNamesBoxesNotChecked`, `TestEgressGrantChecksOnlyAddedAddresses` (Review Focus 4), and through the door: `TestPolicyDoorReplyCarriesNotChecked`, `TestPolicyDoorWritesNothingWhenTheCheckRefuses` (no audit event, no policy object).
- [ ] **Step 2: Run** `go test ./internal/console -run 'TestEgressGrant|TestPolicyDoorReplyCarriesNotChecked|TestPolicyDoorWritesNothingWhenTheCheckRefuses' -count=1`. Expected: FAIL.
@@ -411,7 +466,7 @@ const egressAlertEvery = 24 * time.Hour
- Create: `internal/host/egress_status.go`, test in `internal/status/snapshot_test.go` and `internal/host/egress_status_test.go`
**Interfaces:**
-- Consumes: `(*instancectl.Client).EgressStatus` (Task 4).
+- Consumes: `(*instancectl.Client).EgressStatus` (Task 4); `provision.PolicyReadStates()` (plan 1).
- Produces:
```go
@@ -424,12 +479,13 @@ type HostEgress struct {
}
// Probes.Egress map[string]HostEgress; BoxMetrics.Egress map[string]HostEgress `json:"egress,omitempty"` (private Metrics, hostname-keyed)
// internal/host
-func egressStatusProbe(c interface{ EgressStatus(context.Context) (instancectl.EgressStatus, error) }) map[string]status.HostEgress
+func egressStatusProbe(c interface{ EgressStatus(context.Context) (instancectl.EgressStatus, error) },
+ rows []registry.Route, agentStates map[string]string) map[string]status.HostEgress
```
-The probe asks root with a 5 s timeout; an error leaves the field nil (absent, "not reported", never "none"), logged throttled. A box with no `eb.Control` passes nil. Byte-identical: a box with no hosts reported publishes no `egress` key.
+Before claims the agent is the one that verifies the record (plan 1), so root only ever sees an emptied column. `agentStates` is `provision.PolicyReadStates()`: for each instance row whose `<account>/<app>` has an entry and whose root report has no `State`, the probe sets `State` to `unreadable`, `unverifiable` or `expired` (the root words, from plan 1's `policy unreadable` / `policy unverifiable` / `policy expired`) so the same `!policy expired` reaches the display either way. Under claims root's own state wins. The probe asks root with a 5 s timeout; an error leaves the field nil (absent, "not reported", never "none"), logged throttled. A box with no `eb.Control` passes nil. Byte-identical: a box with no hosts reported publishes no `egress` key.
-- [ ] **Step 1: Failing tests:** `TestCollectBoxCarriesEgress`, `TestCollectBoxOmitsEmptyEgress`, `TestEgressStatusProbeConvertsAndFailsQuiet`.
+- [ ] **Step 1: Failing tests:** `TestCollectBoxCarriesEgress`, `TestCollectBoxOmitsEmptyEgress`, `TestEgressStatusProbeConvertsAndFailsQuiet`, `TestEgressStatusProbeCarriesTheAgentsPolicyState` (row `ab12/shop`, `agentStates{"ab12/shop": "policy expired"}`, root reports `{}` → `State: "expired"`; root reports `failing` → `failing` kept).
- [ ] **Step 2: Run** `go test ./internal/status -run 'TestCollectBox.*Egress' -count=1 && go test ./internal/host -run 'TestEgressStatusProbe' -count=1`. Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** Step 2's commands. Expected: PASS.
@@ -440,7 +496,7 @@ The probe asks root with a 5 s timeout; an error leaves the field nil (absent, "
### Task 12: Display: `status --layout` and `instances list` gain `EGRESS`; `apps show` marks `!`
**Files:**
-- Modify: `internal/console/apistatuslayout.go` (`apiStatusLayoutRow` 25-32, `handleAPIStatusLayout` 77-140), `internal/console/apiinstances.go` (record type ~180-200, `boxJoin` 347, `joinBoxes` 596-620, the record build at ~809), `status_instances.go` (`statusLayoutRow` 141-146, `printStatusLayout` 212-281), `instances_cli.go` (`instancesListHeader` 455, `runInstancesList` 558-600), `apimode.go` (`apiInstanceRecord` 3260), plan 1's `apps show` response and renderer (find by `egress_allow` in `internal/console` and the root package)
+- Modify: `internal/console/apistatuslayout.go` (`apiStatusLayoutRow` 25-32, `handleAPIStatusLayout` 77-140), `internal/console/apiinstances.go` (record type ~180-200, `boxJoin` 347, `joinBoxes` 596-620, the record build at ~809), `status_instances.go` (`statusLayoutRow` 141-146, `printStatusLayout` 212-281), `instances_cli.go` (`instancesListHeader` 455, `runInstancesList` 558-600), `apimode.go` (`apiInstanceRecord` 3260), plan 1's `internal/console/apipolicy.go` (`policyReply` gains `EgressConflicts []string \`json:"egress_conflicts,omitempty"\``, filled by `handleAPIPolicyGet`) and `apps_policy.go` (`apiAppPolicy` gains the same field; `runAppsShow` marks them; the `apps set --egress-allow` success path prints `appsSetEgressNotChecked` when the reply's `not_checked` is non-empty)
- Test: `status_instances_test.go:67` (heading), `instances_cli_test.go`, `internal/console/apistatuslayout_test.go`, `internal/console/apiinstances_test.go`
**Interfaces:**
@@ -460,10 +516,10 @@ type apiEgress struct {
func egressCell(e *apiEgress) string // root package
```
-`egressCell`: nil → `-`; addresses in `Allowed` comma-joined, each in `Conflicts` prefixed `!`; nothing allowed → `-`; then, when `State` is set, ` !<word>` with the word from Task 17's table (`failing`, `policy unreadable`, `policy for another incarnation`, `box not wholly nested`); when only `Refused` is set, ` !refused`. `status --layout`'s heading becomes `BOX HOST LAYOUT EGRESS REASON`; its REASON cell appends `egress: <refused or detail>` when present. `instances list` gains `EGRESS` after `DB`. `apps show` prefixes `!` on configured addresses in `egressConflicts()` (plan 1's response gains `egress_conflicts`, a list of addresses). An old console's answer (no `egress`) shows `-`.
+`egressCell`: nil → `-`; addresses in `Allowed` comma-joined, each in `Conflicts` prefixed `!`; nothing allowed → `-`; then, when `State` is set, ` !<word>` with the word from Task 17's table (`failing`, `policy unreadable`, `policy unverifiable`, `policy expired`, `policy replayed`, `policy for another incarnation`, `box not wholly nested`); when only `Refused` is set, ` !refused`. `status --layout`'s heading becomes `BOX HOST LAYOUT EGRESS REASON`; its REASON cell appends `egress: <refused or detail>` when present. `instances list` gains `EGRESS` after `DB`. `apps show` prefixes `!` on configured addresses in `egressConflicts()` (plan 1's GET reply gains `egress_conflicts`, a list of addresses). `apps set --egress-allow` prints, after `appsSetEgressDone`, the draft `appsSetEgressNotChecked` = `Not checked against these boxes, which publish no address list: %s.` with the reply's `not_checked` comma-joined, only when it is non-empty. An old console's answer (no `egress`) shows `-`.
-- [ ] **Step 1: Failing tests:** `TestEgressCellTable` (nil, none, two allowed, one conflict, each state, refused only); `TestStatusLayoutShowsEgress` (heading and a row); `TestInstancesListShowsEgress`; `TestAPIStatusLayoutCarriesEgressAndConflicts`; `TestAPIInstancesJoinsEgress` (stale box ignored, as `dbBytes`); `TestAppsShowMarksConflicts`.
-- [ ] **Step 2: Run** `go test . -run 'TestEgressCell|TestStatusLayout|TestInstancesListShowsEgress|TestAppsShowMarksConflicts' -count=1 && go test ./internal/console -run 'TestAPIStatusLayoutCarriesEgress|TestAPIInstancesJoinsEgress' -count=1`. Expected: FAIL.
+- [ ] **Step 1: Failing tests:** `TestEgressCellTable` (nil, none, two allowed, one conflict, each state, refused only); `TestStatusLayoutShowsEgress` (heading and a row); `TestInstancesListShowsEgress`; `TestAPIStatusLayoutCarriesEgressAndConflicts`; `TestAPIInstancesJoinsEgress` (stale box ignored, as `dbBytes`); `TestAppsShowMarksConflicts`; `TestAppsSetPrintsNotChecked` (reply `not_checked: ["box-c"]` → the line; `[]` → no line).
+- [ ] **Step 2: Run** `go test . -run 'TestEgressCell|TestStatusLayout|TestInstancesListShowsEgress|TestAppsShowMarksConflicts|TestAppsSetPrintsNotChecked' -count=1 && go test ./internal/console -run 'TestAPIStatusLayoutCarriesEgress|TestAPIInstancesJoinsEgress' -count=1`. Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** Step 2's commands and `go test . -run 'TestStatusInstances|TestApprovedCopy' -count=1`. Expected: PASS (the approved-copy pins change only in Task 17).
- [ ] **Step 5: Commit** `cli: EGRESS in status --layout and instances list; apps show marks fleet addresses (#328)`.
@@ -477,12 +533,12 @@ func egressCell(e *apiEgress) string // root package
- Modify: `internal/host/plan14_installed_launch_root_linux_test.go` (add an `egress:` mode to `plan14InstalledClaimLaunch` 149-...)
**Interfaces:**
-- Consumes: everything above. Installs templates from `unitsFS` the way `TestRootFilesSeedIsolatedTenant` does (`claimfilesseed_isolated_rootrunner_linux_test.go:100-175`, `/opt/carlos/carlos` = the test binary, `BindsTo=`/`Requires=`/`After=` dropped, cleanup of `/run/systemd/system.control/<slice>.d`), and the dummy-link fixture from Task 1 (factor it into a helper in Task 1's file: `egressDummyLink(t) (addrs map[string]string)`).
+- Consumes: everything above (the asserter built with `newEgressAsserter(src, hosts)`, two arguments). Installs templates from `unitsFS` the way `TestRootFilesSeedIsolatedTenant` does (`claimfilesseed_isolated_rootrunner_linux_test.go:100-175`, `/opt/carlos/carlos` = the test binary, `BindsTo=`/`Requires=`/`After=` dropped, cleanup of `/run/systemd/system.control/<slice>.d`), and the dummy-link fixture from Task 1 (factor it into a helper in Task 1's file: `egressDummyLink(t) (addrs map[string]string)`).
-- [ ] **Step 1: Write `TestRootEgressStartPathsPreClaim`**: a real `instancectl.Server` with `Egress: newEgressAsserter(registryEgressSource(dir), registryEgressHosts(dir), 0)`, served on a scratch unix socket with `PeerOK` accepting the test, a temp registry whose nested row for `shop-a.example.com` carries `EgressAllow: "10.0.5.20"`, and `instancectl.Client` driving each verb: `Start(run)`, `StartGen`, `Seed`, `StartSidecarExe`, `Cmd`. After each, `systemctl show -p IPAddressAllow --value <slice>` is `10.0.5.20/32` and, for the serving roles, the tenant's `?dial=10.0.5.20:<port>` answers 204.
+- [ ] **Step 1: Write `TestRootEgressStartPathsPreClaim`**: a real `instancectl.Server` with `Egress: newEgressAsserter(registryEgressSource(dir), registryEgressHosts(dir))`, served on a scratch unix socket with `PeerOK` accepting the test, a temp registry whose nested row for `shop-a.example.com` carries `EgressAllow: "10.0.5.20"`, and `instancectl.Client` driving each verb: `Start(run)`, `StartGen`, `Seed`, `StartSidecarExe`, `Cmd`. After each, `systemctl show -p IPAddressAllow --value <slice>` is `10.0.5.20/32` and, for the serving roles, the tenant's `?dial=10.0.5.20:<port>` answers 204.
- [ ] **Step 2: Write `TestRootEgressRevokeWhileRunning`**: with the run unit serving, clear the column and `POST /egress`: dial answers 403 within 2 s. Then make systemctl's next write fail once (a `Systemctl` wrapper that fails the first `set-property` on that slice), clear again, and assert the next `Sweep` closes it and the report went `failing` then `""`.
- [ ] **Step 3: Write `TestRootEgressMixedBoxWritesNothing`**: add a flat row; `POST /egress`; the slice holds nothing and `GET /egress` says `not_nested`.
-- [ ] **Step 4: Add `TestPlan14EgressClaimLaunchRootRunner`** (`plan14InstalledClaimLaunch(t, "egress:")`): the harness's store fixture gets a policy at `apppolicy.Key` with the app's `app_id`; the claim launch through `ClaimUnits.StartReserved` (and so `claimEffectiveUnit`) succeeds, the tenant reaches `10.0.5.20`, and with the policy's `app_id` changed the next sweep closes it with `other_incarnation`.
+- [ ] **Step 4: Add `TestPlan14EgressClaimLaunchRootRunner`** (`plan14InstalledClaimLaunch(t, "egress:")`): the harness's store fixture gets a policy signed with the harness's release key at `apppolicy.Key` with the app's `app_id` (revision 2); the claim launch through `ClaimUnits.StartReserved` (and so `claimEffectiveUnit`) succeeds, the tenant reaches `10.0.5.20`, and `/opt/carlos/system/guard/app-policy/<sqid>.<app>.json` (real `rootstate`) says revision 2. Then: restart the control service (a new source and asserter over the same floor directory), put a revision 1 record granting `10.0.5.20` back at the key, and `POST /egress`: the slice is reset and `GET /egress` says `replayed`. Then a revision 3 record with another `app_id`: the next sweep closes it with `other_incarnation`.
- [ ] **Step 5: Cross-compile** `GOOS=linux GOARCH=arm64 go test -c -tags rootrunner -o $TMPDIR/egress-root.test ./internal/host && rm $TMPDIR/egress-root.test`. Expected: builds.
- [ ] **Step 6: Run on the root runner**, sandbox off: `--run '^(TestRootEgressStartPathsPreClaim|TestRootEgressRevokeWhileRunning|TestRootEgressMixedBoxWritesNothing|TestPlan14EgressClaimLaunchRootRunner)$'`. Expected: PASS; run id in the report.
- [ ] **Step 7: Commit** `host: production-wired egress witnesses for every start path and a live revoke (#328)`.
@@ -520,7 +576,7 @@ func egressCell(e *apiEgress) string // root package
- Modify: `internal/provision/plan14_root_reads_test.go` (`TestPlan14RootReadCensus`, after the last overlay load at ~1100: load this one last, after `plan14-root-reads-ci-speed-activator.tsv` or after plan 1's overlay if plan 1 added one, checking the previous seal first)
- [ ] **Step 1: Run** `go test ./internal/provision -run '^TestPlan14RootReadCensus$' -count=1`. Expected: FAIL listing the new units.
-- [ ] **Step 2: Write the overlay and the proposal** in the shape of `census-proposal-moves-offline-fixes.md`, every row `rev3=PROPOSED-instance-egress`. Units and executing identities to classify: root (`carlos-instance-control`): `EgressAsserter.Assert`, `Sweep`, `Loop`, `populatedHostSlices` (cgroupfs read), `Server.handleEgress*`, the five start-path changes and `ClaimUnits.start`; `registryEgressSource`, `registryEgressHosts`, `boxWhollyNested` (root reading the agent-written registry and `layout.JournalPresent`, pre-claim only); `storeEgressSource` (root reading the store; no agent-written input). The agent (user carlos): `egressNudgeLoop`, `boxAddrs` (procfs), `egressStatusProbe`. Propose dispositions against main's siblings (`instanceRegistryHostApp`, `allowResolvers`, `rootInstallationHostApp`) and state the trust argument from spec §4.3 for each root registry read. Re-pin the envelopes of `internal/instancectl`, `internal/host`, `internal/edgesfile`, `internal/status`; record the new seal.
+- [ ] **Step 2: Write the overlay and the proposal** in the shape of `census-proposal-moves-offline-fixes.md`, every row `rev3=PROPOSED-instance-egress`. Units and executing identities to classify: root (`carlos-instance-control`): `EgressAsserter.Assert`, `Sweep`, `Loop`, `populatedHostSlices` (cgroupfs read), `Server.handleEgress*`, the five start-path changes and `ClaimUnits.start`; `registryEgressSource`, `registryEgressHosts`, `boxWhollyNested` (root reading the agent-written registry and `layout.JournalPresent`, pre-claim only); `newStoreEgressSource` (root reading the store and `CARLOS_RELEASE_PUBKEY`, verifying the signed record; no agent-written input) and its floor (`appPolicyFloorDir`, a root write under `/opt/carlos/system/guard/app-policy/`, the class of the moves and seeds floors); `release.GetIfNoneMatch` (a conditional read, same class as `Get`). The agent (user carlos): `egressNudgeLoop`, `boxAddrs` (procfs), `egressStatusProbe`. Propose dispositions against main's siblings (`instanceRegistryHostApp`, `allowResolvers`, `rootInstallationHostApp`) and state the trust argument from spec §4.3 for each root registry read. Re-pin the envelopes of `internal/instancectl`, `internal/host`, `internal/edgesfile`, `internal/status`, `internal/release`, `internal/apppolicy`; record the new seal.
- [ ] **Step 3: Run** Step 1's command. Expected: PASS.
- [ ] **Step 4: Commit** `census: overlay for the egress allow's root reads (proposed, #280) (#328)`, then post the proposal to the #280 thread and ask for the ruling. Merge waits for it.
@@ -531,7 +587,7 @@ func egressCell(e *apiEgress) string // root package
**Files:**
- Modify: wherever Tasks 9, 10 and 12 put the drafts; `approved_copy_test.go` (or the matching `approved_*_copy_test.go`) and `status_instances_test.go:67` to pin the approved text
-- [ ] **Step 1: Collect the strings** with their file and line: the `EGRESS` heading and the new `status --layout` heading; the four state words and `refused` (and `pending` if Task 1 required it); `egress: ` REASON prefix; the four grant refusals and the `not checked` line plan 1's CLI prints from `not_checked`; the alert subject and body; root's log lines that an operator reads in `carlos logs` (`no host for this slice; its allow is closed`, the failing line). Leave out JSON keys and internal error fragments.
+- [ ] **Step 1: Collect the strings** with their file and line: the `EGRESS` heading and the new `status --layout` heading; the seven state words (`failing`, `policy unreadable`, `policy unverifiable`, `policy expired`, `policy replayed`, `policy for another incarnation`, `box not wholly nested`) and `refused` (and `pending` if Task 1 required it); `egress: ` REASON prefix; the four grant refusals and `appsSetEgressNotChecked` (Task 12); the alert subject and body; root's log lines that an operator reads in `carlos logs` (`no host for this slice; its allow is closed`, the failing line). Leave out JSON keys and internal error fragments.
- [ ] **Step 2: Invoke the `copy-review` skill** with that list and wait for Paul's edits.
- [ ] **Step 3: Apply the approved text verbatim**, then count each changed string's occurrences at the base commit before replacing (shared strings need their own review).
- [ ] **Step 4: Run** `go test . -run 'TestApprovedCopy|TestStatusLayout|TestInstancesListShowsEgress|TestEgressCell' -count=1 && go test ./internal/console -run 'TestEgressGrant|TestEgressReport' -count=1`. Expected: PASS.
@@ -541,5 +597,5 @@ func egressCell(e *apiEgress) string // root package
## Self-review notes
-- Spec coverage: §4.1 (Task 2, root side; door side is plan 1), §4.2 (Tasks 8, 9, 10, root's own-address drop in Task 2), §4.3 sources (Tasks 5, 6), write and when (Tasks 2, 3, 4, 7), kernel proof (Task 1), §4.4 (Tasks 11, 12), §3 `app_id` (Task 6), §7 byte-identical (Task 15), §11 egress items (Tasks 1, 2, 9, 10, 13, 14, 15; the fleet-door refusal in Task 6). Sizes are plan 3.
+- Spec coverage: §4.1 (Task 2, root side; door side is plan 1), §4.2 (Tasks 8, 9, 10, root's own-address drop in Task 2), §4.3 sources (Tasks 5, 6), write and when (Tasks 2, 3, 4, 7), kernel proof (Task 1), §4.4 (Tasks 11, 12), §3 `app_id` (Task 6), §3 rev 8 signed record verified by root, revision floor in root's state surviving a restart, `policy replayed` / `policy expired` / `policy unverifiable`, per-app 30 s conditional read (Task 6, witnessed in Task 13), §4.3 rev 8 sidecar members (Task 5), §7 byte-identical (Task 15), §11 egress items (Tasks 1, 2, 9, 10, 13, 14, 15; the fleet-door refusal in Task 6). Sizes are plan 3.
- Not this plan's: the policy object, door, CLI `apps set`, purge sweep, store-door read grant and the agent's column convergence (plan 1). Task 6 Step 1 refuses to proceed if the refusal-is-not-absence contract is missing.
diff --git a/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md b/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md
index 0e5df1e11..abafec07f 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md
@@ -4,11 +4,11 @@
**Goal:** Make the four named sizes (`small`, `standard`, `large`, `xlarge`) real on the box: every validator takes them, every start path runs at the instance's effective size, growth reaches a running instance live, memory shrinks wait for a cold start, and the operator sees the size in force and how much of each box is committed.
-**Architecture:** `internal/cgroup`'s size table (names from plan 1) becomes the source of every number. The activator's tier seam (`ExecBackend.tier`, `GenTier`) reads the registry `size` column (plan 1) before the build's tier, so run, sidecar, seed, cmd, parent, generation and the claim snapshot all carry the effective size with no per-path change. Root gains `POST /size` and `GET /size` (slice, then serving generation unit, then read-back). The activator owns live changes under the same per-entry operation set as swaps, sets the captured `Gen.Tier` from root's read-back, and blocks swaps while a change is in flight or unsettled. Boxes publish `mem_total_mib` in their edges file and a committed figure in their status snapshot; the console's policy door refuses a size above a quarter of the smallest box and reports the committed figure.
+**Architecture:** `internal/cgroup`'s size table (names from plan 1) becomes the source of every number. The activator's tier seam (`ExecBackend.tier`, `GenTier`) reads the registry `size` column (plan 1) before the build's tier, so run, sidecar, seed, cmd, parent, generation and the claim snapshot all carry the effective size with no per-path change. Root gains `POST /size` and `GET /size` (slice, then serving generation unit, then read-back), as legacy verbs: under host claims they answer 503 and a new size waits for the next start (spec §5.4 rev 8). The activator owns live changes under the same per-entry operation set as swaps, sets the captured `Gen.Tier` from root's read-back, and blocks swaps while a change is in flight or unsettled. Boxes publish `mem_total_mib` in their edges file and a committed figure in their status snapshot; the console's policy door refuses a size above a quarter of the smallest box and reports the committed figure.
**Tech Stack:** Go, systemd (`set-property --runtime`, `show`), cgroup v2, SQLite registry, S3-compatible store.
-**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 7, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`. Read §5 and the §9 dispositions R1-1, R1-2, R1-3, R2-1, R3-1 before simplifying anything.
+**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 8, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`. Read §5 and the §9 dispositions R1-1, R1-2, R1-3, R2-1, R3-1 before simplifying anything.
## Global Constraints
@@ -22,7 +22,9 @@
- User-facing strings go through Task 14 (copy review) before merge. Plain English, no em dashes. Internal error fragments and JSON keys are not copy.
- No tenant names in code, tests or comments: `shop-a.example.com`, `10.0.5.20`.
- Commits per task, ending with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
-- Nothing is on by default: with an empty `size` column, every unit, slice and probe line is byte-identical to main (Task 12), with the one exception the spec's §5.4 gate requires (Task 4).
+- Nothing is on by default: with an empty `size` column, every unit, slice and probe line, and every swap's raise and restore, is byte-identical to main (Task 12). The swap max rule applies only with an operator size (Task 4, spec §5.4 rev 8).
+- Under host claims there is no live size change (spec §5.4 rev 8): the legacy control verbs answer 503 there, and a new size, growth included, takes effect at the host's next start (Tasks 5, 6).
+- No below-tier warning anywhere (spec §5.2 rev 8 drops it): the console cannot see a build's `children` bit, and no task here implements one.
- The size table, verbatim from spec §5.1 (`MemoryHigh` is always `HighOf(max) = max / 10 * 9`):
| size | MemoryMax (bytes) | MemoryHigh | CPUQuota | CPUWeight | IOWeight |
@@ -36,18 +38,19 @@
- `cgroup.Size{Name; Limit; Shares}`, `cgroup.Sizes` (order small, standard, large, xlarge), `cgroup.SizeByName(name) (Size, bool)`, `cgroup.SizeOf(l Limit) (Size, bool)`.
- `registry.Route.Size string` (`routes.size`, `''` = the build's tier) and `(*Registry).SetPolicy(host, egressAllow, size string) error`.
-- The policy door `PUT /api/cli/apps/{account}/{app}/policy` and its handler; the CLI's `apps set --size` and `instances set --size`.
+- The policy door `PUT /api/cli/apps/{account}/{app}/policy` (`handleAPIPolicyPut`, `internal/console/apipolicy.go`) and its reply `{policy, etag, not_checked, stale_overrides}`, to which Task 9 adds `committed`; the CLI's `apps set --size` and `instances set --size` (`apps_policy.go`, `appsSetRun`, `instancesSetRun`).
+- `provision.ReconcileConfig.PolicyChanged func(host string)`: called after each converge of a row's policy columns (Task 7 sets it to `NoteSize`).
-Not in this plan: `carlos apps show` (plan 1) and the §5.2 CLI warning ("this build declares children; small may be OOM-killed"), which needs the build's `children` bit at the console. No console code reads that bit today; whoever owns the CLI decides how it gets it.
+Not in this plan: `carlos apps show` (plan 1). There is no below-tier CLI warning: spec §5.2 rev 8 drops it, so no plan implements one.
-## Sitting beside plan 2 in `internal/instancectl`
+## Sitting after plan 2 in `internal/instancectl`
-Plan 2 adds an `IPAddressAllow=` write to every start path (`handleStart`, `startGen`, `handleSeed`, `handleCmd`, `ClaimUnits.Start`) and a `POST /egress` route. This plan changes, in those files, only:
+This plan is built after plan 2. Plan 2 adds an `IPAddressAllow=` write to every start path (`handleStart` for run and sidecar, `handleSeed`, `startGen`, `handleCmd`, `ClaimUnits.start`), immediately after the tier step and before `systemctl start`, and a `POST /egress` route. This plan sits after that egress step: it neither moves it nor edits those bodies around it. It changes, in those files, only:
- `server.go` `parseRequest` (the `v.tier` switch, ~381-392) and `Handler` (two new routes beside plan 2's `/egress`, ~323-331);
- `server.go` `sliceTier` (~443-454), refactored with identical output;
- `gen.go` `startGen` (one line before `writeGenDropIn`, ~145) and `removeGenFiles` (~83-101);
- `claimunit.go` `claimTier` (~67-73).
-Merge after plan 2: plan 2's egress write sits after the `setTier` block in `startGen` (~161-166) and `handleStart` (~511-516); this plan's line in `startGen` sits before `writeGenDropIn`. `handleStart`, `handleSeed` and `handleCmd` bodies are untouched here, and the populated-slice skips (`gen.go:161`, `server.go:511`, `cmd.go:84`) stay exactly as they are (R1-1).
+Plan 2's egress write sits after the tier block in `startGen` (~161-166) and `handleStart` (~503-516); this plan's one line in `startGen` sits before `writeGenDropIn` (~145), above both. `handleStart`, `handleSeed` and `handleCmd` bodies are untouched here, and the populated-slice skips (`gen.go:161`, `server.go:511`, `cmd.go:84`) stay exactly as they are (R1-1).
## Review Focus
@@ -93,11 +96,23 @@ func TestValidParentLimitIsOneSizeOrTwo(t *testing.T)
func TestMaxLimit(t *testing.T) // MaxLimit(TierDefault, TierChildren) == TierChildren both ways round
```
-Extend `TestStrictRejectsUnavailableManagerHostAndTier` so `Limit{MemoryMax: 7}` is still `ErrStrictRefused` and the small and xlarge limits are not refused on shape (they reach the state checks), and add a `PrepareGen` case for small and xlarge in `strict_gen_linux_test.go` asserting `tierExpected(size.Limit, "1")`.
+The two strict validators (spec §5.3 rev 8: `prepareStrict`, `strict_linux.go:216`, behind `PrepareStrict` and `PrepareStrictParent`; `PrepareGen`, `strict_gen_linux.go:162`) take exactly one size, never a sum:
+
+```go
+// strict_linux_test.go, beside TestStrictRejectsUnavailableManagerHostAndTier (which stays as it is)
+func TestPrepareStrictTakesEverySize(t *testing.T)
+// m := &Manager{root: "/sys/fs/cgroup"}; for each of Sizes: PrepareStrict("x.example", size.Limit)
+// is not ErrStrictRefused (it reaches the state checks); Limit{MemoryMax: 7} and the 768 MiB sum
+// (805306368) are ErrStrictRefused, through PrepareStrict and PrepareStrictParent alike.
+// strict_gen_linux_test.go, beside TestPrepareGenWritesTheFullTier
+func TestPrepareGenWritesEverySize(t *testing.T)
+// the TestPrepareGenWritesTheFullTier fixture over the four sizes: the child's files equal
+// tierExpected(size.Limit, "1"); PrepareGen(n, Limit{MemoryMax: 805306368}) is ErrStrictRefused.
+```
- [ ] **Step 2: Run them to see them fail**
-Run: `go test ./internal/cgroup/ -run 'TestSizeTable|TestTiersAre|TestSharesOf|TestValidParentLimit|TestMaxLimit|TestStrictRejects|TestPrepareGen'`
+Run: `go test ./internal/cgroup/ -run 'TestSizeTable|TestTiersAre|TestSharesOf|TestValidParentLimit|TestMaxLimit|TestStrictRejects|TestPrepareStrictTakesEverySize|TestPrepareGen'`
Expected: FAIL (small and xlarge refused by `ValidParentLimit`, strict validators; `MaxLimit` undefined).
- [ ] **Step 3: Implement**
@@ -192,39 +207,44 @@ Expected: FAIL.
- [ ] **Step 5: Commit** (`activator: the size column is the tier source for every spawn and the claim snapshot`)
-### Task 4: A swap's successor starts at the larger size
+### Task 4: With an operator size (now or at the incumbent's start), a swap's successor starts at the larger size
**Files:**
-- Modify: `internal/activator/swap.go:493-508` (the captured tiers)
-- Test: `internal/activator/swap_overlap_test.go:71-100` (`TestSwapOverlapUsesEachArtifactsTier`)
+- Modify: `internal/activator/swap.go:493-506` (the captured tiers: `succTier` at 501, `m.gen.Tier` at 506)
+- Test: `internal/activator/swap_overlap_test.go` (a new test beside `TestSwapOverlapUsesEachArtifactsTier`, which is **not** changed)
**Interfaces:**
-- Consumes: `cgroup.MaxLimit` (Task 1), `GenTier` (Task 3).
-- Produces: `m.gen.Tier = cgroup.MaxLimit(gb.GenTier(rt, at.Build), incumbent.Tier)`. The raise is `OverlapLimit(incumbent.Tier, succTier)` as today; the restore (`leaveSet`, `retryParentRestoreLocked`) is unchanged and so never lowers memory.
-
-This is the one deliberate change with no policy set: a release whose build drops `children` now keeps the incumbent's 1 GiB until a cold start, where main lowered it at the restore (spec §5.4, "no swap ever lowers memory"). Task 12 states it.
+- Consumes: `cgroup.MaxLimit` (Task 1), `GenTier` (Task 3), `registry.Route.Size` (plan 1).
+- Produces: `succTier := gb.GenTier(rt, at.Build)`; then `if rt.Size != "" || incumbent.Tier != gb.GenTier(rt, incumbent.Build) { succTier = cgroup.MaxLimit(succTier, incumbent.Tier) }`; `m.gen.Tier = succTier`. The raise is `OverlapLimit(incumbent.Tier, succTier)` as today; the restore (`leaveSet`, `retryParentRestoreLocked`) is unchanged, so with an operator size it never lowers memory.
-- [ ] **Step 1: Change the existing test and add one**
+Spec §5.4 rev 8: the max rule applies only when the host has an operator-set size (its registry `size` is non-empty) **or the incumbent runs at a size other than its own build's tier** (an operator size was in force at its start, or it was grown live, and has since been cleared: without this a cleared size would let a swap lower memory). With no operator size each artifact keeps its own tier exactly as on main, so a release that drops `children` still restores to 512 MiB at the swap and a host with no policy is byte-identical (Task 12). `rt` is the entry's route at the swap, whose `Size` Task 6 keeps current.
-In `TestSwapOverlapUsesEachArtifactsTier/switched`: incumbent v1 `TierChildren`, successor v2 `TierDefault`; now `res.Gen.Tier == cgroup.TierChildren`, events contain `raise:1073741824+1073741824` and `restore:1073741824`, and the spawned successor's `g.Tier == TierChildren`. The `failed` subtest is unchanged (`restore:1073741824`).
+- [ ] **Step 1: Write the failing test**
```go
-func TestSwapSuccessorStartsAtTheLargerOfEffectiveAndIncumbent(t *testing.T) {
+func TestSwapSuccessorStartsAtTheLargerSizeOnlyWithAnOperatorSize(t *testing.T) {
t.Parallel()
- // (a) incumbent captured at TierDefault, f.b.tiers v2 = TierChildren (an
- // operator grew it): successor 1 GiB, raise 512 MiB + 1 GiB, restore 1 GiB.
- // (b) incumbent captured at 2 GiB (grown live, Task 6), v2 = TierDefault:
- // successor 2 GiB, raise 2 GiB + 2 GiB, restore 2 GiB.
+ // (a) route Size "large", incumbent captured at TierDefault (grown by the operator; the size
+ // column now makes GenTier(v2) 1 GiB): successor 1 GiB, raise 512 MiB + 1 GiB, restore 1 GiB.
+ // (b) route Size "standard", incumbent captured at 2 GiB (grown live to xlarge, Task 6, then the
+ // operator chose standard): successor 2 GiB, raise 2 GiB + 2 GiB, restore 2 GiB; the shrink
+ // waits for a cold start.
+ // (c) route Size "", f.b.tiers v1 = TierChildren, v2 = TierDefault: successor 512 MiB,
+ // raise 1 GiB + 512 MiB, restore 512 MiB (main's behaviour, the same events as
+ // TestSwapOverlapUsesEachArtifactsTier/switched).
+ // (d) route Size "" (the operator cleared it), incumbent captured at 2 GiB while its build's
+ // tier is TierDefault (grown live, then cleared): successor 2 GiB, raise 2 GiB + 2 GiB,
+ // restore 2 GiB; never a lowering restore. The shrink waits for a cold start.
}
```
-- [ ] **Step 2: Run to see them fail**: `go test ./internal/activator/ -run 'TestSwapOverlapUsesEachArtifactsTier|TestSwapSuccessorStartsAtTheLarger'`. Expected: FAIL.
+- [ ] **Step 2: Run to see it fail**: `go test ./internal/activator/ -run 'TestSwapOverlapUsesEachArtifactsTier|TestSwapSuccessorStartsAtTheLarger'`. Expected: the new test FAILS on (a) and (b); `TestSwapOverlapUsesEachArtifactsTier` PASSES unchanged.
-- [ ] **Step 3: Implement** the one-line change at `swap.go:498`, with a comment citing spec §5.4 and R1-3.
+- [ ] **Step 3: Implement** the conditional at `swap.go:501-506`, with a comment citing spec §5.4 (rev 8) and R1-3: why it is conditional (a host with no policy stays byte-identical) and why it never lowers memory when it applies.
-- [ ] **Step 4: Run** `go test ./internal/activator/ -run 'Swap'`. Expected: PASS.
+- [ ] **Step 4: Run** `go test ./internal/activator/ -run 'Swap'`. Expected: PASS, with `TestSwapOverlapUsesEachArtifactsTier` untouched.
-- [ ] **Step 5: Commit** (`activator: a swap's successor starts at the larger of its size and the incumbent's`)
+- [ ] **Step 5: Commit** (`activator: with an operator size, a swap's successor starts at the larger of its size and the incumbent's`)
### Task 5: Root's `/size` verbs
@@ -242,7 +262,7 @@ func TestSwapSuccessorStartsAtTheLargerOfEffectiveAndIncumbent(t *testing.T) {
- `func sliceSize(ctx, systemctl, host string, mem cgroup.Limit, sh cgroup.Shares) error`; `sliceTier(ctx, systemctl, host, tier)` = `sliceSize(..., tier, cgroup.SharesOf(tier))`, output unchanged.
- `POST /size?host=&size=<name>&memory_max=<n>[&gen=<n>]` → 200 with `SizeReadBack`; `GET /size?host=[&gen=<n>]` → 200 with `SizeReadBack`.
- `var ErrSizeRefused = errors.New("instance control: size refused before any write")`
- - `func (c *Client) Size(ctx context.Context, host string, gen uint64, size string, memoryMax int64) (SizeReadBack, error)`: 400, 409 and 503 wrap `ErrSizeRefused`. 503 is `claimAwareLegacy`'s answer with host claims on: no live change is made under claims, and the new size lands at the next claim launch (shown as pending).
+ - `func (c *Client) Size(ctx context.Context, host string, gen uint64, size string, memoryMax int64) (SizeReadBack, error)`: 400, 409 and 503 wrap `ErrSizeRefused`. 503 is `claimAwareLegacy`'s answer with host claims on: **under claims there is no live change** (spec §5.4 rev 8), growth included, and the new size lands at the host's next start (shown as pending). A live verb for claim mode is follow-up work, not this plan's.
- `func (c *Client) ReadSize(ctx context.Context, host string, gen uint64) (SizeReadBack, error)`
- `func SliceControlGroup(host string) string` in `names.go`: `path.Join(parentSlices, SliceName(host))`, for the agent's read in Task 7.
@@ -329,7 +349,7 @@ under a.mu:
clear resizing; close(resizeDone)
```
-The activator never sends a change while a parent is raised: a change that arrives mid-swap waits, because `retrySizeLocked` skips until the set is empty and the restore has succeeded. A change with no operator involvement (a release that changes the build's tier) sends nothing live: `sizeSeen` only moves when the column does.
+The activator never sends a change while a parent is raised: a change that arrives mid-swap waits, because `retrySizeLocked` skips until the set is empty and the restore has succeeded. A change with no operator involvement (a release that changes the build's tier) sends nothing live: `sizeSeen` only moves when the column does. Under host claims the call is answered 503 → `ErrSizeRefused`: nothing is written, `sizeSeen` records the column, and the size takes effect at the next start (no live change under claims, spec §5.4 rev 8). An agent-cleared column (an expired or unverifiable policy, plan 1) is an ordinary change to `''`: `want` is the build's tier and `MaxLimit` keeps memory where it is, so only CPU and IO move live.
- [ ] **Step 1: Write the failing tests** (swap fixture; the fake records `size:<name>/<mem>/<gen>` and `readsize:<gen>` events and returns scripted read-backs)
@@ -353,11 +373,15 @@ func TestNoLiveCallWithoutAnOperatorChange(t *testing.T)
// Size "" throughout, build tier changes v1 -> v2 by a swap: no size or readsize event, ever.
func TestNoLiveCallForAsleepWorkerOrFlat(t *testing.T)
// entry asleep, a worker, or ResizeLive returning errSizeNotLive: no unsettled mark, no retry storm (one call at most).
+func TestNoLiveChangeUnderClaims(t *testing.T)
+// ResizeLive returning an error wrapping ErrSizeRefused (root's 503 under claims), Size "" -> "xlarge":
+// one call, no unsettled mark, Current.Tier unchanged, SizeState.Want xlarge (pending); no second call on
+// the next sweep; the next spawn (a wake after sleep) carries 2 GiB.
func TestJoinOpsWaitsForAResizeInFlight(t *testing.T)
// ResizeLive gated; a stop joins and returns only after the gate opens.
```
-- [ ] **Step 2: Run to see them fail**: `go test ./internal/activator/ -run 'LiveGrowth|LiveChangeWaits|SwapRefusedWhileResizing|MemoryShrink|SizeBack|NoLiveCall|JoinOpsWaitsForAResize'`. Expected: FAIL.
+- [ ] **Step 2: Run to see them fail**: `go test ./internal/activator/ -run 'LiveGrowth|LiveChangeWaits|SwapRefusedWhileResizing|MemoryShrink|SizeBack|NoLiveCall|NoLiveChangeUnderClaims|JoinOpsWaitsForAResize'`. Expected: FAIL.
- [ ] **Step 3: Implement** as above. `ExecBackend.ResizeLive`/`ReadSize` mirror `ParentLimit`'s layout switch (`containment.go:365-386`).
@@ -368,7 +392,7 @@ func TestJoinOpsWaitsForAResizeInFlight(t *testing.T)
### Task 7: The agent wires sizes and reports them
**Files:**
-- Modify: plan 1's convergence pass (where it calls `reg.SetPolicy`): when a row's `Size` changed, call `svc.NoteSize(host)`
+- Modify: `internal/host/agent.go` (~1560-1605, where the agent builds `provision.ReconcileConfig`): set `cfg.PolicyChanged` to the armed `Service`'s `NoteSize` (plan 1's hook, called after each converge of a row's policy columns; an egress-only change makes a harmless no-op pass)
- Modify: `internal/host/agent.go` (~405-430, where the `ExecBackend` and activator `Service` are built: store `armedSizeSource`), `agent.go:3857` (status metrics: `SizeCommittedMiB`)
- Create: `internal/host/sizestatus.go` (`armedSizeSource`, `readSizeInForce`, `sizeCell`, `sizeCommittedMiB`)
- Modify: `internal/host/layoutleg.go:182-216` (`layoutStatusReport` fills `RowLayout.Size` for activator-managed rows)
@@ -376,7 +400,7 @@ func TestJoinOpsWaitsForAResizeInFlight(t *testing.T)
- Test: `internal/host/sizestatus_test.go` (create), `internal/host/layoutleg_test.go` (or the file holding `layoutStatusReport` tests)
**Interfaces:**
-- Consumes: `Service.SizeState`, `Service.NoteSize`, `ExecBackend.EffectiveSize` (Tasks 3, 6), `instancectl.SliceControlGroup`, `instancectl.GenUnitName`.
+- Consumes: `Service.SizeState`, `Service.NoteSize`, `ExecBackend.EffectiveSize` (Tasks 3, 6), `instancectl.SliceControlGroup`, `instancectl.GenUnitName`, `provision.ReconcileConfig.PolicyChanged` (plan 1).
- Produces:
- `var armedSizeSource atomic.Pointer[sizeSource]`; `type sizeSource struct { svc *activator.Service; backend *activator.ExecBackend; cgroupRoot string }`
- `func readSizeInForce(cgroupRoot, host string, gen uint64) (cgroup.Size, bool)`: `memory.max` of the generation unit's cgroup when `gen > 0` and it exists, else of the slice; matched by `cgroup.SizeOf` on memory alone (spec §5.4 "Read-back"). Not a row, or unreadable: false.
@@ -398,7 +422,7 @@ func TestSizeCommittedSumsAwakeEffectiveSizes(t *testing.T) // two awake (small,
- [ ] **Step 2: Run to see them fail**: `go test ./internal/host/ -run 'SizeCell|ReadSizeInForce|LayoutReportCarriesSize|SizeCommitted'`. Expected: FAIL.
-- [ ] **Step 3: Implement**; wire `NoteSize` in plan 1's convergence pass and store `armedSizeSource` once the `Service` exists (`cgroupRoot` `/sys/fs/cgroup`).
+- [ ] **Step 3: Implement**; set `cfg.PolicyChanged` to call `NoteSize` on the armed source's `Service` (nil-safe before it is armed) and store `armedSizeSource` once the `Service` exists (`cgroupRoot` `/sys/fs/cgroup`).
- [ ] **Step 4: Run** the step 2 command. Expected: PASS. Cross-compile check: `GOOS=darwin go build ./internal/host/ && GOOS=windows go build ./internal/host/`.
@@ -436,7 +460,7 @@ func TestPlacementViewReadsMemTotal(t *testing.T) // parse; absent -> 0
**Files:**
- Create: `internal/console/policysize.go` (`sizeBoxes`, `sizeCapacityCheck`, `sizeCommitted`)
-- Modify: plan 1's policy door handler: after shape validation and before the audit write, for a change that sets `default_size` or a `size` value other than `default`, call `sizeCapacityCheck`; on success, add `committed` to the reply
+- Modify: `internal/console/apipolicy.go` `handleAPIPolicyPut` (plan 1): after shape validation and before `checkEgressGrant` and the audit write, for a change that sets `default_size` or a `size` value other than `default`, call `sizeCapacityCheck`; on success, add `committed` to the reply
- Test: `internal/console/policysize_test.go` (create)
**Interfaces:**
@@ -474,7 +498,7 @@ func TestPolicyDoorRefusesAnOversizeBeforeTheAuditWrite(t *testing.T) // through
**Files:**
- Modify: `internal/console/apistatuslayout.go:22-32` (`apiStatusLayoutRow.Size string \`json:"size,omitempty"\``), `:111-115` (copy `rl.Size`)
- Modify: `status_instances.go:212-250` (`printStatusLayout`: a `SIZE` column after `LAYOUT`; one footnote when any cell holds `>`)
-- Modify: plan 1's `apps set --size` and `instances set --size` CLI: print the reply's `committed` lines with `printSizeCommitted`
+- Modify: `apps_policy.go` (plan 1; `apiAppPolicy` gains `Committed []sizeCommittedBox \`json:"committed,omitempty"\``) and the `--size` success paths of `appsSetRun` (`apps_updates.go`) and `instancesSetRun` (`instances_updates.go`): print the reply's `committed` lines with `printSizeCommitted`
- Test: `status_instances_test.go` (or the file holding `printStatusLayout` tests), `internal/console/apistatuslayout_test.go`
**Interfaces:**
@@ -549,10 +573,13 @@ func TestNoSizeColumnIsByteIdenticalToMain(t *testing.T)
func TestNoSizeColumnSendsNoLiveCall(t *testing.T)
// activator: a wake, a swap and ten sweeps with Size "" make no size or readsize call, and every
// spawn's tier equals the Tier/TierOf seam's.
+func TestNoSizeColumnSwapIsMains(t *testing.T)
+// activator: Size "", a swap from a children build (v1) to one without (v2): raise 1 GiB + 512 MiB,
+// restore 512 MiB, successor at 512 MiB, exactly main's events (spec §5.4 rev 8: no exception).
```
-Write in the test file's top comment, and in the commit message, the one stated exception: a swap to a build that drops `children` keeps the incumbent's memory until a cold start (Task 4, spec §5.4).
+There is no exception to state: with no operator size every swap is main's (Task 4).
-- [ ] **Step 2: Run** `TMPDIR=/tmp/g go test ./internal/instancectl/ -run NoSizeColumn && go test ./internal/activator/ -run NoSizeColumn`. Expected: PASS.
+- [ ] **Step 2: Run** `TMPDIR=/tmp/g go test ./internal/instancectl/ -run NoSizeColumn && go test ./internal/activator/ -run 'NoSizeColumn|TestSwapOverlapUsesEachArtifactsTier'`. Expected: PASS.
- [ ] **Step 3: Commit** (`sizes: prove an empty size column is byte-identical to main`)
diff --git a/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md b/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md
index 53fd8abac..2c81fd662 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md
@@ -2,7 +2,7 @@
For the three plan writers. The spec is
`docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md`
-(rev 7, Astra ACCEPT round 7, approved by Paul 2026-10-21). Its §0 rulings
+(rev 8, which folds in what the plan writers found; rev 7 was Astra ACCEPT round 7, approved by Paul 2026-10-21). Its §0 rulings
and §8 calls are settled; do not reopen them. Its §9 dispositions record
why each mechanism is shaped the way it is: read them before simplifying
anything.
@@ -11,8 +11,8 @@ anything.
| # | File | Covers |
|---|---|---|
-| 1 | `2026-10-21-instance-policy-1-foundation.md` | The policy object (spec §3), its validation, the operator door and audit (§6), the CLI (`apps set --egress-allow/--size`, `instances set --size`, `apps show`), the purge sweep and create-time clear (§3), the store door's read grant (§4.3 Transport), and the agent converging the policy into registry columns (pre-claim delivery). |
-| 2 | `2026-10-21-instance-policy-2-egress.md` | The kernel proof first (§4.3), address validation in root (§4.1), root writing and re-asserting `IPAddressAllow=` on every start path and on a 30 s loop (§4.3), the claim-mode store read with the `app_id` binding (§3, §4.3), the not-wholly-nested rail, `addrs` publication and the grant-time check and report (§4.2), read-back and display (§4.4). |
+| 1 | `2026-10-21-instance-policy-1-foundation.md` | The policy object (spec §3) as a signed record (rev 8: sign, verify, revision, 72 h lifetime, re-sign on the door keys' loop), its validation, the operator door and audit (§6), the CLI (`apps set --egress-allow/--size`, `instances set --size`, `apps show`), the purge sweep and create-time clear (§3), the store door's read grant (§4.3 Transport), and the agent converging the policy into registry columns (pre-claim delivery). |
+| 2 | `2026-10-21-instance-policy-2-egress.md` | The kernel proof first (§4.3), address validation in root (§4.1), root writing and re-asserting `IPAddressAllow=` on every start path and on a 30 s loop (§4.3), the claim-mode store read with signature verification, a per-app revision floor in root's state, a per-app 30 s conditional read and the `app_id` binding (§3, §4.3), the not-wholly-nested rail, `addrs` publication and the grant-time check and report (§4.2), read-back and display (§4.4). |
| 3 | `2026-10-21-instance-policy-3-sizes.md` | The size table (§5.1), every validator (§5.3), the effective size as the box's tier source (§5.2, §5.3), live growth and the captured tier (§5.4), the cold-start-only memory shrink and swap rule (§5.4), `mem_total_mib` and the capacity check (§5.5), read-back and display (§5.6). |
Build order: 1, then 2 (Tito is blocked on egress), then 3. Plans 2 and 3
@@ -27,10 +27,15 @@ Plan 1 produces these; plans 2 and 3 use them by these exact names.
- **Package `internal/apppolicy`** (new):
- `const Prefix = "control/app-policy/"`; `func Key(sqid, app string) string`
returns `Prefix + sqid + "/" + app + ".json"`.
- - `type Policy struct { V int \`json:"v"\`; AppID string \`json:"app_id,omitempty"\`; EgressAllow []string \`json:"egress_allow,omitempty"\`; DefaultSize string \`json:"default_size,omitempty"\`; Sizes map[string]SizeOverride \`json:"sizes,omitempty"\` }`
+ - `type Policy struct { V int \`json:"v"\`; Account string \`json:"account"\`; App string \`json:"app"\`; Revision uint64 \`json:"revision"\`; SignedAt time.Time \`json:"signed_at"\`; AppID string \`json:"app_id,omitempty"\`; EgressAllow []string \`json:"egress_allow,omitempty"\`; DefaultSize string \`json:"default_size,omitempty"\`; Sizes map[string]SizeOverride \`json:"sizes,omitempty"\` }` (rev 8: `Account`, `App`, `Revision`, `SignedAt` are the signed record's fields, as in `doorkey.Record`)
- `type SizeOverride struct { Size string \`json:"size"\`; CreatedAt time.Time \`json:"created_at"\` }`
- - `func Get(ctx context.Context, s release.Store, sqid, app string) (*Policy, string, error)`: nil policy and `os.ErrNotExist` when absent.
- - `func PutIfMatch(ctx context.Context, s release.Store, sqid, app string, p Policy, etag string) error`
+ - **The signed record** (spec §3 rev 8, the `internal/doorkey` precedent): `const Lifetime = 72 * time.Hour`; domain separator `carlos app-policy v1\n`; wire `{"payload": base64(Policy JSON), "sig": base64(ed25519 over domain+payload)}`.
+ - `func Sign(priv ed25519.PrivateKey, p Policy) ([]byte, error)`
+ - `func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Policy, error)`: signature, shape, unexpired; errors wrap `ErrUnverifiable` or `ErrExpired`.
+ - `func VerifyRevision(pub ed25519.PublicKey, wire []byte) (uint64, error)`: the revision of a validly signed record, expired or not.
+ - `func VerifyKey(pub ed25519.PublicKey, wire []byte, now time.Time, sqid, app string) (Policy, error)`: `Verify` plus "the record names this account and app".
+ - `func Get(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string, now time.Time) (*Policy, string, error)`: the verified policy (`VerifyKey`). Nil policy and `os.ErrNotExist` when absent; nil policy, the ETag and `ErrUnverifiable` / `ErrExpired` when the object does not verify.
+ - `func PutIfMatch(ctx context.Context, s release.Store, priv ed25519.PrivateKey, p Policy, etag string) error`: signs `p` and writes it at `Key(p.Account, p.App)`. The console's door signs every write at a revision one above the current, and re-signs every live policy on the door keys' 12-hour loop.
- `func Delete(ctx context.Context, s release.Store, sqid, app string) error`
- `func (p Policy) Validate() error`: shape only (version, size names known to `cgroup.SizeByName`, at most 8 allows, each parsing under `egressaddr.Parse`).
- **Package `internal/egressaddr`** (new, plan 1 creates it because the door validates; plan 2 extends nothing in it, only calls it):
@@ -46,7 +51,10 @@ Plan 1 produces these; plans 2 and 3 use them by these exact names.
- `routes.egress_allow TEXT NOT NULL DEFAULT ''`: the normalized list, space-separated.
- `routes.size TEXT NOT NULL DEFAULT ''`: the effective size name, `''` meaning the build's tier.
- `func (r *Registry) SetPolicy(host, egressAllow, size string) error`, and both fields on `registry.Route` as `EgressAllow string` and `Size string`.
-- **Door:** `PUT /api/cli/apps/{account}/{app}/policy` with body `{"egress_allow": [...] | null, "default_size": "..." | null, "size": {"host": "...", "value": "..."} | null}`, `If-Match` the policy ETag; reply carries the new policy, its ETag, and (plan 2 fills it) `not_checked` boxes. Gate: `apiSystemRoller`, empty `svcrole.Set{}`. Audit before write; refuse if the audit write fails.
+- **Door:** `PUT /api/cli/apps/{account}/{app}/policy` with body `{"egress_allow": [...] | null, "default_size": "..." | null, "size": {"host": "...", "value": "..."} | null}`, `If-Match` the policy ETag (or `If-None-Match: *`); reply `{"policy": {...}, "etag": "...", "not_checked": [...], "stale_overrides": [...]}`, where plan 2 fills `not_checked` (through plan 1's `checkEgressGrant` stub) and adds `egress_conflicts` to the GET reply, and plan 3 adds `committed`. `GET` on the same path answers the same shape. Gate: `apiSystemRoller`, empty `svcrole.Set{}`. Audit before write; refuse if the audit write fails.
+- **Agent hooks** (plan 1's `internal/provision`): `ReconcileConfig.PolicyChanged func(host string)` (plan 3 sets it to the activator's `NoteSize`); `PolicyReadStates() map[string]string` (plan 2 reports it).
+- **Sidecar members** (`Kind "sidecar"` rows) get no policy, and any such row makes a box not wholly nested (spec §4.3 rev 8): plan 1 leaves their columns empty, plan 2 writes no allow on that box before claims.
+- **Sizes under claims:** no live change; a new size takes effect at the host's next start (spec §5.4 rev 8). No plan implements a below-tier CLI warning (spec §5.2 rev 8 drops it).
Any name a plan needs that is not here is that plan's to choose and pin in
its own Interfaces blocks.
diff --git a/docs/superpowers/plans/CODEX-REVIEW-BRIEF-instance-policy-plans.md b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-instance-policy-plans.md
new file mode 100644
index 000000000..709660885
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-REVIEW-BRIEF-instance-policy-plans.md
@@ -0,0 +1,31 @@
+# Review brief: instance policy plans 1-3
+
+Review the three implementation plans in `docs/superpowers/plans/`:
+`2026-10-21-instance-policy-1-foundation.md`, `-2-egress.md`, `-3-sizes.md`,
+with their brief `2026-10-21-instance-policy-PLAN-BRIEF.md`, against the
+approved spec `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md`
+(rev 8) and the code in this repository, at the commit you are run on.
+
+The spec is approved: its §0 rulings, §8 calls and §9 dispositions are
+fixed. Do not argue the design. Report only where a plan:
+- fails to implement a spec requirement, or implements it differently;
+- names a file, function, line range, type or test helper that does not
+ exist or does not do what the plan says (cite file:line);
+- would, if built as written, break an anti-goal in spec §2 (a forgeable
+ or misdirected allow, an allow reaching another app or the probe, an OOM
+ from a size change, a no-policy host that is not byte-identical, a
+ revoke left open);
+- disagrees with another plan on a shared name, signature or start-path
+ order;
+- has a test that would pass while the behaviour is wrong (a fixture or a
+ sleep standing in for real wiring on a root or claim path).
+
+Output: a numbered list, each with severity (P1 would ship a defect or
+cannot be built as written; P2 must fix in the plan; P3 worth fixing), the
+plan and task, the evidence with file:line, and the smallest fix. Then one
+line: ACCEPT or REJECT (reject if any P1). No preamble.
+
+## Round 2 and later
+
+First one line per previous finding (RESOLVED, PARTLY, NOT RESOLVED) from
+`CODEX-FINDINGS-instance-policy-plans-R<n>.md`, then only new findings.
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 e445e649b..05bf1da84 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
@@ -153,7 +153,9 @@ that way.
- **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.
+ unchanged. An app with no policy object is re-read every 5 minutes (and on
+ any start or agent nudge), so a first grant reaches running instances
+ within 5 minutes even if the agent says nothing.
- **The policy dies with its app** (Astra R5-1). An app's name is freed
by a purge (`PurgeAppRow`, `internal/console/store.go:1731`: "a later
app of the same name starts clean"), and the bucket purge already
@@ -449,8 +451,10 @@ are: they protect the overlap allowance (Astra R1-1).
- when the host has an operator-set size (its registry `size` is not
empty), a swap's successor starts at **the larger of** the effective
size and the incumbent's size, so the restore never lowers the
- parent. With no operator size, each artifact keeps its own tier as
- today, so a host with no policy is byte-identical (rev 8: the
+ parent. The same holds when the incumbent runs at a size other than
+ its own build's tier (an operator size since cleared). With no
+ operator size now or at the incumbent's start, each artifact keeps
+ its own tier as today, so a host with no policy is byte-identical (rev 8: the
unconditional rule changed a release that drops `children`);
- a smaller memory size takes effect only at a **cold start**, when
the slice is empty (a wake from hibernation, or a restart the
@@ -526,7 +530,7 @@ default and the overrides.
multi-tenant fleet an app owner who could pick `xlarge` would bypass
Carloku's plan caps. Self-service can come with plans and caps.
- The four sizes and their numbers (§5.1).
-- Shrinking below the build's tier is allowed with a warning (§5.2).
+- Shrinking below the build's tier is allowed, with no warning (§5.2).
- Memory shrinks wait for a cold start; a swap never lowers memory (§5.4).
- Fleet addresses: checked at the grant, reported after, never enforced
by root (§4.2; Paul's ruling on my proposal).