carlos / platform Public

spec rev 8 (signed policy, plan writers' code findings) + plans 1-3 first drafts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bedb89483b4bc20bba819e91af0b867e7720813a parent b06ce41
4 files changed, +1942 −9
  • docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md +745 −0
  • docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md +545 −0
  • docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md +589 −0
  • docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md +63 −9
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
new file mode 100644
index 000000000..de8fe3485
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
@@ -0,0 +1,745 @@
+# Instance policy, plan 1 of 3: foundation. Implementation Plan
+
+> **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.
+
+**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.
+
+**Tech Stack:** Go, SQLite (modernc), `net/netip`, 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`.
+
+## Global Constraints
+
+- Go; module `github.com/carlosframework/platform`. Leave `GOCACHE` at its default. Scratch under `$TMPDIR`. Socket tests: `TMPDIR=/tmp/g`.
+- 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`.
+- 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.
+- 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).
+- Pinned names (brief, "Cross-plan interfaces") are used exactly. Names this plan adds are pinned in each task's Interfaces block.
+
+## Review Focus
+
+1. **A host name deleted and recreated** keeps its old override in the object. Expect: the new instance runs at the app default; `apps show` marks the entry stale; the next policy write drops it. Tests: Task 7 `TestReconcileIgnoresAnOverrideForAnotherIncarnation`, Task 8 `TestPolicyGetMarksAndPutDropsStaleOverrides`.
+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`.
+
+## Boundary verification (spec §3 precondition), recorded
+
+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.
+
+## Interfaces produced for plans 2 and 3
+
+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`.
+- `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.
+- `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.
+
+---
+
+### Task 1: `internal/egressaddr`
+
+**Files:**
+- Create: `internal/egressaddr/egressaddr.go`
+- Test: `internal/egressaddr/egressaddr_test.go`
+- Modify: `internal/host/units_hardening_test.go` (add one test after `TestInstanceDenyCoversTheDocEngineFence`, line ~107)
+
+**Interfaces:**
+- Produces (pinned): `Parse(s string) (netip.Addr, error)`, `Allowed(a netip.Addr) error`, `var FenceDeny []netip.Prefix`, `Normalize(in []string) ([]string, error)`; plus `const MaxAllow = 8`.
+
+`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:
+
+| const | text |
+|---|---|
+| `msgEmpty` | `an address is empty` |
+| `msgRange` | `%q is a range. Allow single addresses only` |
+| `msgPort` | `%q has a port. An allow opens an address, never a port` |
+| `msgZone` | `%q has a zone. Name the address without it` |
+| `msgNotAddr` | `%q is not an IP address` |
+| `msgLoopback` | `%s is a loopback address` |
+| `msgUnspecified` | `%s is the unspecified address` |
+| `msgMetadata` | `%s is the cloud metadata service` |
+| `msgLinkLocal` | `%s is link-local` |
+| `msgMulticast` | `%s is a multicast address` |
+| `msgReserved` | `%s is in %s, a reserved range` |
+| `msgNAT64` | `%s is in the NAT64 prefix 64:ff9b::/96, which reaches any IPv4 address` |
+| `msgPublic` | `%s is a public address and is already reachable. No allow is needed` |
+| `msgTooMany` | `at most %d addresses per app (%d given)` |
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestParseRefusesNonAddresses(t *testing.T) {
+ for in, want := range map[string]string{
+ "": "an address is empty", "10.0.5.0/24": "is a range", "10.0.5.20:5432": "has a port",
+ "[fd00:5::20]:80": "has a port", "fe80::1%eth0": "has a zone", "db.example.com": "is not an IP address",
+ "010.0.5.20": "is not an IP address",
+ } { /* Parse(in) errors and the message contains want */ }
+}
+func TestParseUnmapsAndCanonicalizes(t *testing.T) {
+ // "::ffff:10.0.5.20" -> 10.0.5.20 (Is4); " 10.0.5.20 " -> 10.0.5.20; "FD00:5::20" -> fd00:5::20
+}
+func TestAllowedRefusesEveryClass(t *testing.T) {
+ refused := map[string]string{
+ "127.0.0.1": "loopback", "::1": "loopback", "0.0.0.0": "unspecified", "::": "unspecified",
+ "169.254.169.254": "metadata", "fd00:ec2::254": "metadata", "169.254.1.1": "link-local",
+ "fe80::1": "link-local", "224.0.0.1": "multicast", "ff02::1": "multicast",
+ "0.1.2.3": "reserved", "192.0.0.8": "reserved", "198.18.0.1": "reserved", "240.0.0.1": "reserved",
+ "255.255.255.255": "reserved", "fec0::1": "reserved", "64:ff9b::a00:514": "NAT64",
+ "8.8.8.8": "already reachable", "2001:db8::1": "already reachable",
+ }
+ allowed := []string{"10.0.5.20", "100.64.0.9", "172.16.3.4", "192.168.1.10", "fd00:5::20", "fc00::1", "fd00:ec2::253"}
+ // each refused address: Allowed errors and the message contains the class word
+ // each allowed address: Allowed returns nil
+}
+func TestEveryAllowedAddressIsInsideTheFence(t *testing.T) {
+ // for each `allowed` above, some FenceDeny prefix contains it
+}
+func TestNormalize(t *testing.T) {
+ // ["fd00:5::20","10.0.5.20","10.0.5.20",""] -> ["10.0.5.20","fd00:5::20"] (v4 before v6, dupes and empties gone)
+ // nil and [""] -> nil, nil
+ // nine distinct private addresses -> error containing "at most 8 addresses per app (9 given)"
+ // ["10.0.5.20","8.8.8.8"] -> error containing "8.8.8.8 is a public address"
+}
+```
+
+In `internal/host/units_hardening_test.go`:
+
+```go
+// The fence the templates write and the table an allow must fall inside
+// (egressaddr.FenceDeny) are one list.
+func TestInstanceFenceIsEgressaddrFenceDeny(t *testing.T) {
+ t.Parallel()
+ // For each of the six template names in TestNamespaceBlockCarriesTheHardening,
+ // and for claimUnitTemplate's text (claimunit_template.go:48): take the
+ // IPAddressDeny= line's fields, expand "localhost" -> 127.0.0.0/8 ::1/128 and
+ // "multicast" -> 224.0.0.0/4 ff00::/8, parse each with netip.ParsePrefix, and
+ // assert the set equals the set of egressaddr.FenceDeny exactly.
+}
+```
+
+- [ ] **Step 2: Run them and see them fail**
+
+Run: `go test ./internal/egressaddr ./internal/host -run 'TestParse|TestAllowed|TestEveryAllowed|TestNormalize|TestInstanceFenceIsEgressaddrFenceDeny'`
+Expected: FAIL, package `egressaddr` does not exist.
+
+- [ ] **Step 3: Implement `internal/egressaddr/egressaddr.go`**
+
+Package comment: what the package is for (the one rule set the console door and root both apply, spec §4.1), and that `FenceDeny` is pinned to the templates by the host test. `Parse`: trim; empty refused; contains `/` → range; `netip.ParseAddrPort` succeeds → port; `netip.ParseAddr` fails → not an address; `Zone() != ""` → zone; return `a.Unmap()`. `Allowed` checks in the order of the test table: loopback, unspecified, the two metadata addresses, `IsLinkLocalUnicast`, `IsMulticast`, the reserved prefixes (`0.0.0.0/8 192.0.0.0/24 198.18.0.0/15 240.0.0.0/4 fec0::/10`), NAT64, then "inside some `FenceDeny` prefix" else public. `Normalize`: Parse and Allowed each non-empty trimmed entry, dedupe on the `netip.Addr`, sort with `netip.Addr.Compare`, count against `MaxAllow` after the dedupe, return `a.String()` forms; empty result is `nil`. Do not import `internal/instancectl` (root's package): `ResolverAllowed` is mirrored, not called, and the comment says so.
+
+- [ ] **Step 4: Run the tests and see them pass**
+
+Same command. Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/egressaddr internal/host/units_hardening_test.go
+git commit -m "egressaddr: the egress-allow address rules and the fence table (#328)"
+```
+
+---
+
+### Task 2: Size names in `internal/cgroup`
+
+**Files:**
+- Create: `internal/cgroup/sizes.go`
+- Test: `internal/cgroup/sizes_test.go`
+
+**Interfaces:**
+- Produces (pinned): `type Size struct { Name string; Limit Limit; Shares Shares }`, `var Sizes []Size`, `SizeByName(name string) (Size, bool)`, `SizeOf(l Limit) (Size, bool)`; plus `SizeNames() []string` (`["small","standard","large","xlarge"]`, for messages).
+- Plan 1 changes no behaviour: `TierDefault`, `TierChildren`, `SharesOf` and `HighOf` are untouched.
+
+| Name | `Limit.MemoryMax` | `Shares{CPUWeight, CPUQuotaPercent, IOWeight}` |
+|---|---|---|
+| `small` | `256 << 20` | `{50, 50, 50}` |
+| `standard` | `TierDefault.MemoryMax` (`512 << 20`) | `{100, 100, 100}` |
+| `large` | `TierChildren.MemoryMax` (`1 << 30`) | `{100, 200, 100}` |
+| `xlarge` | `2 << 30` | `{200, 400, 200}` |
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestSizesTable(t *testing.T) {
+ // names in order small, standard, large, xlarge; the four rows above exactly
+ // Sizes[1].Limit == TierDefault && Sizes[1].Shares == SharesOf(TierDefault)
+ // Sizes[2].Limit == TierChildren && Sizes[2].Shares == SharesOf(TierChildren)
+ // SizeByName: each name ok; "", "Large", "default", "medium" not ok
+ // SizeOf(TierDefault).Name == "standard"; SizeOf(Limit{MemoryMax: 3 << 30}) not ok
+ // SizeNames() == []string{"small","standard","large","xlarge"}
+}
+```
+
+- [ ] **Step 2: Run, fail:** `go test ./internal/cgroup -run TestSizesTable` → FAIL (undefined).
+- [ ] **Step 3: Implement `sizes.go`.** `standard` and `large` are written as `TierDefault` / `TierChildren` and `SharesOf(...)` of them, so they cannot drift. Comment: plan 3 makes this the source of every number (spec §5.1); today only the console validates against it.
+- [ ] **Step 4: Run, pass.**
+- [ ] **Step 5: Commit** `cgroup: size names (#329)`.
+
+---
+
+### Task 3: `internal/apppolicy` 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`
+
+**Interfaces:**
+- Consumes: Task 1 `egressaddr.Parse`, `MaxAllow`; Task 2 `cgroup.SizeByName`.
+- Produces (pinned): `Prefix`, `Key`, `Policy`, `SizeOverride`, `Get`, `PutIfMatch`, `Delete`, `(Policy).Validate`. Added:
+ - `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.
+- `SizeFor`: the override for `host` if its `CreatedAt.Equal(createdAt)`, else `DefaultSize`, else `""`. `EgressColumn`: `strings.Join(EgressAllow, " ")`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestKeyShape(t *testing.T) {
+ // Key("ab12","shop") == "control/app-policy/ab12/shop.json" == release.AppPolicyKey("ab12","shop")
+}
+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
+ // PutIfMatch(..., same, "") -> errors.Is(err, release.ErrCASConflict) // create-only
+ // Get -> p equal, etag1; PutIfMatch(..., p2, etag1) ok; PutIfMatch(..., p3, etag1) -> ErrCASConflict
+ // Delete; Get -> os.ErrNotExist; Delete again -> nil
+}
+func TestValidate(t *testing.T) {
+ // ok: {V:1}; {V:1, AppID:"x", EgressAllow:["10.0.5.20","fd00:5::20"], DefaultSize:"small",
+ // Sizes:{"shop-a.example.com":{"xlarge", t0}}}
+ // ErrInvalid: V 0 and 2; DefaultSize "medium"; Sizes value "default"; Sizes zero CreatedAt;
+ // 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 TestSizeForHonoursTheBinding(t *testing.T) {
+ // p := {V:1, DefaultSize:"large", Sizes:{"shop-a.example.com": {"xlarge", t0}}}
+ // SizeFor("shop-a.example.com", t0) == "xlarge"
+ // SizeFor("shop-a.example.com", t0.Add(time.Second)) == "large" // recreated host
+ // SizeFor("shop-b.example.com", t1) == "large"
+ // (&Policy{V:1}).SizeFor(...) == ""; (*Policy)(nil).SizeFor(...) == ""; (*Policy)(nil).EgressColumn() == ""
+ // SizeFor survives a JSON round trip of the record time (t0 from time.Now(), marshalled and parsed)
+}
+func TestPresentListsPolicies(t *testing.T) { /* two seeded keys -> both in the map, nothing else */ }
+```
+
+- [ ] **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 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)`.
+
+---
+
+### Task 4: Registry columns and `SetPolicy`
+
+**Files:**
+- Modify: `internal/registry/registry.go`: `Route` struct (add after `UpdateSettings`, ~line 316), migration list (append after `import_gate`, line 610), `routeCols`/`routeColCount` (893-903: append `egress_allow, size`; count 57), `scanRoute` (910-922), Upsert's `ON CONFLICT` (1207-1210), `routeArgs` (1261-1273), new `SetPolicy` after `SetUpdateSettings` (1972-1993), `snapshotWhenSetCols` (3305-3312)
+- Modify: `internal/registry/remote_dispatch.go` (fields after `UpdateSettings` line 58; case after `SetUpdateSettings` line 244)
+- Test: `internal/registry/policycols_test.go`; `internal/registry/registry_test.go:64` (`TestUpsertRoundTripsAllColumns` gains both fields)
+
+**Interfaces:**
+- Produces (pinned): `Route.EgressAllow string`, `Route.Size string`, `func (r *Registry) SetPolicy(host, egressAllow, size string) error`.
+- Upsert: both columns follow `update_settings`' rule: a tenant change takes the incoming values (empty included); the same tenant keeps an existing value when the incoming one is `''`.
+- Neither column is in `Fingerprint()` (they change what root writes, not what the edge serves). Both are in `snapshotWhenSetCols`, so an unset column hashes nothing and a registry with no policy digests exactly as before.
+- Remote: `remoteRequest` gains `EgressAllow string` and `Size string`; method name `"SetPolicy"`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestPolicyColumns(t *testing.T) {
+ reg := openTestRegistry(t)
+ // Upsert {Host:"shop-a.example.com", Kind:"instance", Account:"ab12", App:"shop"}
+ // new row: EgressAllow "" and Size ""
+ // f0, s0 := claimFingerprints(t, reg)
+ // SetPolicy(host, "10.0.5.20 fd00:5::20", "large") -> Get shows both
+ // Fingerprint == f0 (edge reload not moved); SnapshotFingerprint != s0
+ // Upsert same tenant without the fields -> both kept
+ // SetPolicy(host, "", "") -> both cleared; SnapshotFingerprint == s0 exactly
+ // SetPolicy("nobody.example.com", ...) -> error
+}
+func TestTenantChangeUpsertTakesTheNewTenantsPolicy(t *testing.T) {
+ // row for ab12/shop with SetPolicy("10.0.5.20","xlarge"); Upsert same host as ab12/other with no fields
+ // -> EgressAllow "" and Size "" (one tenant's allow never carries into another's row)
+}
+func TestRemoteSetPolicy(t *testing.T) {
+ // through the remote registry fixture TestRemoteRegistryRoundTrip uses (remote_test.go:38):
+ // SetPolicy over the socket lands on the row
+}
+```
+
+- [ ] **Step 2: Run, fail:** `go test ./internal/registry -run 'TestPolicyColumns|TestTenantChangeUpsertTakesTheNewTenantsPolicy|TestRemoteSetPolicy|TestUpsertRoundTripsAllColumns'`.
+- [ ] **Step 3: Implement** in the six places `registry.go:883-892` lists. `SetPolicy` is one `UPDATE routes SET egress_allow = ?, size = ? WHERE host = ?`, erroring on no row, in `SetUpdateSettings`' exact shape (remote branch first). The column comment says: the agent's converge of `control/app-policy/`, space-separated normalized addresses and a size name, `''` = none; root reads them (plans 2, 3); the registry validates nothing (the rails are at the door and in root).
+- [ ] **Step 4: Run, pass:** the same command, then `go test ./internal/registry -run 'Fingerprint|Snapshot|Upsert|Remote|Claim'`.
+- [ ] **Step 5: Commit** `registry: egress_allow and size columns (#328, #329)`.
+
+---
+
+### Task 5: The store door's read grant, and refusal is not absence
+
+**Files:**
+- Modify: `internal/edge/storedoor.go`: `aclRead` (431-445) gains `appPolicyRead(sqid, key)`; `serveObject`'s GET not-found branch (157-162) adds the header; doc comment of `aclRead` gains the grant's paragraph
+- Modify: `internal/fleetapi/fleetapi.go`: `Get` (128-152) and `Open` (157-177)
+- Test: `internal/edge/storedoor_test.go` (rows in `TestStoreDoorACLMatrix`, one new test); `internal/fleetapi/fleetapi_test.go` (new tests)
+
+**Interfaces:**
+- Consumes: `release.AppPolicyPrefix`, `release.AppPolicyKey`, `release.ErrRefused`, `release.ValidApp`.
+- Produces: header `Carlos-Store-Absent: 1` on a 404 for a key `appPolicyRead` granted and the store reports absent. `fleetapi` (`Get` and `Open`), for a key under `release.AppPolicyPrefix`: 200 as today; 404 **with** the header → `os.ErrNotExist`; 404 **without** it → an error wrapping `release.ErrRefused` (a refusal, an older edge, a bad token). Every other key is unchanged.
+
+`appPolicyRead(sqid, key)`: exactly `release.AppPolicyKey(sqid, app)` for some `app` with `release.ValidApp(app)`, i.e. three segments under `control/app-policy/`, the box's own sqid. Not listable (`aclDir`, `aclListScope` unchanged). `aclWrite` unchanged.
+
+- [ ] **Step 1: Write the failing tests**
+
+Matrix rows (`storedoor_test.go`, using the file's `testSqid`):
+
+```go
+{"control/app-policy/" + testSqid + "/web.json", opRead, true, "the box's own app's operator policy (spec §4.3)"},
+{"control/app-policy/" + testSqid + "/web.json", opWrite, false, "operator-only: a box never writes it"},
+{"control/app-policy/" + testSqid + "/web.json", opDelete, false, "nor deletes it"},
+{"control/app-policy/zz99/web.json", opRead, false, "another account's policy"},
+{"control/app-policy/" + testSqid + "/web.json/x", opRead, false, "exact shape only"},
+{"control/app-policy/" + testSqid + "/Web!.json", opRead, false, "a valid app name only"},
+{"control/app-policy/" + testSqid, opRead, false, "the account directory is not an object"},
+{"control/app-policy/" + testSqid + "x/web.json", opRead, false, "sqid must not prefix-match"},
+```
+
+```go
+func TestStoreDoorMarksOnlyAGrantedAbsentPolicy(t *testing.T) {
+ // own absent policy key: 404 with Carlos-Store-Absent: 1
+ // other account's policy key (seeded and absent): 404, no header, body byte-identical to the
+ // "missing in-scope key" refusal in TestStoreDoorRefusalsAreByteIdentical
+ // own absent apps/<sqid>/web/nope.json: 404, no header (nothing else changes)
+}
+```
+
+`fleetapi_test.go` (real `edge.Edge` door from `newTestDoor`):
+
+```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
+ // 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
+}
+func TestPolicyRefusalFromAnOldDoorIsNotAbsence(t *testing.T) {
+ // httptest server answering every GET with a bare 404 (no header), the old door's shape:
+ // Get("control/app-policy/ab12/web.json") -> errors.Is(err, release.ErrRefused), not os.ErrNotExist
+ // Get("apps/ab12/web/x.json") -> errors.Is(err, os.ErrNotExist) // unchanged for other keys
+ // Open of the policy key behaves as Get
+}
+```
+
+- [ ] **Step 2: Run, fail:** `go test ./internal/edge -run 'TestStoreDoorACLMatrix|TestStoreDoorMarksOnlyAGrantedAbsentPolicy|TestStoreDoorRefusalsAreByteIdentical' && go test ./internal/fleetapi -run 'TestPolicy'`.
+- [ ] **Step 3: Implement.** In `fleetapi`, a helper `notFound(key string, h http.Header) error` used by both `Get` and `Open`; its comment says why this one prefix differs from the uniform 404 mapping (Astra R3-3: a refusal read as absence is "no policy"). Note: `fleetapi` imports only `release`, not `apppolicy`.
+- [ ] **Step 4: Run, pass.** Same commands.
+- [ ] **Step 5: Commit** `storedoor, fleetapi: own-account read of the app policy; refusal is not absence (#328)`.
+
+---
+
+### Task 6: The policy key under the console's mutation boundary
+
+**Files:**
+- Modify: `internal/hostops/mutation_classifier.go` `ClassifyMutation` (add a case beside `control/door-keys/`, line ~111)
+- Test: `internal/hostops/apppolicy_classifier_test.go`
+- Create: `internal/apppolicy/writers_test.go`
+
+**Interfaces:**
+- Produces: family `"app-policy"`, scoped to exactly `{sqid, app}`, for `Put` and `Delete` of `release.AppPolicyKey(sqid, app)` with valid account and app; anything else under `control/app-policy/` (and `DeleteIfMatch`) is `ErrUnclassified` or `ErrIdentity`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// Shape of promotionrecord_classifier_test.go.
+func TestClassifyAppPolicyIsExact(t *testing.T) {
+ // key := release.AppPolicyKey("bab","shop")
+ // Put (create), Put over an existing ETag, Delete -> Family "app-policy", NonApp "", one scope {bab shop}
+ // refused: "control/app-policy/BAB!/shop.json", ".../bab/Shop!.json", ".../bab/shop.json/x",
+ // ".../bab/shop.txt", "control/app-policy/bab", DeleteIfMatch of key
+}
+```
+
+```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
+// release.AppPolicyKey outside internal/apppolicy.
+func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
+ // PutIfMatch/PutIfMatchETag: only internal/console/apipolicy.go
+ // 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
+ // Fails naming file:line of any other call.
+}
+```
+
+- [ ] **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 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)`.
+
+---
+
+### Task 7: The agent converges the policy into the columns
+
+**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/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)
+- 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 }`
+ - `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`
+ - `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.
+
+- [ ] **Step 1: Write the failing tests**
+
+A fixture beside `newUpdatesFixture` (`updates_reconcile_test.go:22`), with neutral names: `dirStore(t)`, `writeAccountManifest(t, s, acctID, "Shop")`, `PutManifest(ctx, s, sqid, "shop", Manifest{Schema: 1, App: "shop", Domain: "example.com"})`, cfg `{Region: "ie", DataDir: "/data"}`, record host `shop-a.example.com`, `CreatedAt: t0`.
+
+```go
+func TestReconcileCarriesThePolicyToTheRow(t *testing.T) {
+ // policy {V:1, EgressAllow:["10.0.5.20","fd00:5::20"], DefaultSize:"large"} put with PutIfMatch
+ // 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 ""
+}
+func TestReconcileIgnoresAnOverrideForAnotherIncarnation(t *testing.T) {
+ // override shop-a -> {xlarge, t0.Add(-time.Hour)}, DefaultSize "small" -> row Size "small"
+}
+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 "" ""
+ // a new record minted during the transient pass -> its row is "" "" and converges on the next good pass
+}
+func TestReconcileReadsPoliciesWithOneListing(t *testing.T) {
+ // three apps with records, a policy for one: counting store wrapper sees exactly one List of
+ // "control/app-policy/" and exactly one Get under it per pass; with List failing, one Get per app
+}
+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 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)`.
+
+---
+
+### Task 8: The operator door
+
+**Files:**
+- Create: `internal/console/apipolicy.go`
+- 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.
+- Produces:
+ - `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"`).
+
+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.
+
+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)`.
+
+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.
+
+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`.
+
+- [ ] **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)`)
+
+```go
+func TestPolicyDoorsAreForSystemOperatorsOnly(t *testing.T) {
+ // a RoleMember of the ops account: GET and PUT -> bare 404 identical to /api/cli/system/no-such-door
+ // a service credential: refused by the zero Set (closedDoors)
+ // an unknown app or account for the owner: the same bare 404
+}
+func TestPolicyPutSetsAuditsAndBumps(t *testing.T) {
+ // PUT If-None-Match:* {"egress_allow":["fd00:5::20","10.0.5.20","10.0.5.20"],"default_size":"large"}
+ // -> 200; policy.egress_allow == ["10.0.5.20","fd00:5::20"]; default_size "large"; not_checked == []
+ // stored object equals the reply; reply etag == header ETag == stored etag; serial bumped by 1
+ // one event, action "app-policy", detail contains "egress allow: none -> 10.0.5.20, fd00:5::20"
+}
+func TestPolicyPutIsCompareAndSwap(t *testing.T) {
+ // no precondition -> 428; If-Match "stale" -> 412; If-None-Match:* over an existing object -> 412
+ // two PUTs with the same If-Match: the first 200, the second 412 and its change absent
+}
+func TestPolicyPutRefusesBeforeWriting(t *testing.T) {
+ // {}, {"egress_allow":["10.0.5.20:5432"]}, ["8.8.8.8"], nine addresses, {"default_size":"huge"},
+ // {"size":{"host":"shop-a.example.com","value":"huge"}}, {"size":{"host":"../x","value":"large"}},
+ // {"egress_alow":[]} (misspelt) -> 400; size for a host with no record -> 404
+ // no object written, no event recorded
+}
+func TestPolicyOverrideCapturesTheRecord(t *testing.T) {
+ // create record shop-a (CreatedAt t0) through provision.CreateRecord on fx.rel
+ // PUT {"size":{"host":"SHOP-A.example.com","value":"xlarge"}} -> sizes["shop-a.example.com"] == {xlarge, t0}
+ // PUT {"size":{"host":"shop-a.example.com","value":"default"}} -> no entry
+}
+func TestPolicyGetMarksAndPutDropsStaleOverrides(t *testing.T) {
+ // override for shop-a at t0; delete and recreate the record (new CreatedAt)
+ // GET -> stale_overrides == ["shop-a.example.com"]
+ // PUT {"default_size":"small"} -> the stale entry is gone from the stored object
+}
+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 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
+}
+func TestPolicyPutStampsTheAppIDUnderClaims(t *testing.T) {
+ // claim-enabled fixture (newClaimCase): PUT -> stored app_id == s.store.ClaimAppID(acct, app)
+}
+```
+
+- [ ] **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 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
+
+**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)
+- Modify: `internal/console/server.go` `claimAppWith` (around `createClaimAppRow`, line 4193)
+- Test: `internal/release/trash_test.go`; `internal/console/apipolicy_test.go`
+
+**Interfaces:**
+- Consumes: `release.AppPolicyKey`, `apppolicy.Delete`, `s.appMutation`.
+- Rules: purge deletes the policy before the marker (a purge that dies is retried by the next pass; covers the console's "Delete now" at `ownership.go:514` and the auto-purge at `domains/reconcile.go:271`). Create: with `HostClaim` off, `apppolicy.Delete` on `s.cfg.Releases` **before** `createClaimAppRow`; a failure returns `claimStoreFailed` and the name is not handed out. With `HostClaim` on, the delete runs **after** `createClaimAppRow` inside `s.appMutation(ctx, acct, name, "app-policy-clear", ...)` (no permit exists before the row); a failure is logged and the create stands, because root ignores a policy whose `app_id` is not the app's (spec §3) and no agent path reads it under claims. Both apply to the owner's adopt-existing case too (fail closed: the operator grants again).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// trash_test.go, beside TestPurgeAppSweepsTheAppsLedgers
+func TestPurgeAppSweepsThePolicyBeforeTheMarker(t *testing.T) {
+ // seedApp bab/derp and bab/derpy; policy objects at AppPolicyKey for both
+ // TrashApp + PurgeApp -> derp's policy gone, derpy's kept, marker deleted last
+ // a store whose Delete of the marker fails: PurgeApp errors, derp's policy already gone;
+ // a second PurgeApp completes
+}
+```
+
+```go
+// apipolicy_test.go
+func TestCreatingAnAppClearsAStalePolicy(t *testing.T) {
+ // seed a policy at the key of a name no app holds; create the app through the CLI create door
+ // -> 2xx and the object is gone
+ // a store failing Delete of that key -> the create is refused (claimStoreFailed's answer) and no row exists
+}
+```
+
+- [ ] **Step 2: Run, fail:** `go test ./internal/release -run 'TestPurgeApp' && go test ./internal/console -run 'TestCreatingAnAppClearsAStalePolicy'`.
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run, pass**, plus `go test ./internal/console -run 'TestPurge|TestAppCreate|TestClaimApp'` and `go test ./internal/apppolicy -run TestPolicyWriters`.
+- [ ] **Step 5: Commit** `purge and create clear the app policy (#328, #329)`.
+
+---
+
+### Task 10: CLI: `apps set --egress-allow/--size`, `instances set --size`, `apps show`
+
+**Files:**
+- Create: `apps_policy.go` (constants, client calls, `runAppsShow`)
+- Modify: `apps_swap.go` `runAppsSet` (129-182): two flags; `apps_updates.go` `appsSetRun` (91-140): a third change; `instances_updates.go` `runInstancesSet` (96-145) and `instancesSetRun` (151-177); `apps.go` `RunApps` (141-171) and `appsUsage` (37): the `show` verb; `apimode.go` (`sendJSON`, 866-893)
+- Test: `apps_policy_test.go`; update callers in `apps_updates_test.go` and the `instances set` tests for the new parameters
+
+**Interfaces:**
+- Consumes: the door (Task 8), `egressaddr.Normalize`, `cgroup.SizeByName`.
+- Produces:
+ - `func (s *apiSession) sendJSONHeader(ctx context.Context, method, path string, in any, hdr http.Header) (int, http.Header, []byte, error)`; `sendJSON` calls it with `nil` and drops the header.
+ - `type policyChange struct { EgressAllow *[]string `json:"egress_allow,omitempty"`; DefaultSize *string `json:"default_size,omitempty"`; Size *policySize `json:"size,omitempty"` }`, `type policySize struct { Host string `json:"host"`; Value string `json:"value"` }`, `type apiAppPolicy` (the door's reply).
+ - `func (s *apiSession) getAppPolicy(ctx context.Context, a apiAcct, app string) (apiAppPolicy, error)`; `func (s *apiSession) changeAppPolicy(ctx context.Context, a apiAcct, app string, c policyChange) (apiAppPolicy, error)` (GET, then PUT with `If-Match: <etag>` or `If-None-Match: *`; a 412 is returned as `appsPolicyChanged`, never retried silently).
+ - `func appsPolicyChangeFrom(fs *flag.FlagSet, egress, size string) (*policyChange, error)`: `--egress-allow` split on commas and `egressaddr.Normalize`d client-side (an empty value clears: `&[]string{}`); `--size` is a size name or `default` (→ `""`).
+ - `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):
+
+| const | text |
+|---|---|
+| `appsSetFlagEgress` | `private addresses every instance of the app may reach, comma-separated, never a port. "" clears them. System operators only` |
+| `appsSetFlagSize` | `the size the app's instances run at: small, standard, large or xlarge. default goes back to the build's size. System operators only` |
+| `instancesSetFlagSize` | `this instance's size: small, standard, large or xlarge. default follows the app's size. System operators only` |
+| `appsSetPolicyUsage` | usage addendum naming both flags and that the change reaches the app's boxes within seconds once they run a release that applies it |
+| `appsSetBadEgress` | `apps set: --egress-allow: %v` |
+| `appsSetBadSize` | `apps set: --size is small, standard, large, xlarge or default, not %q` |
+| `instancesSetBadSize` | `instances set: --size is small, standard, large, xlarge or default, not %q` |
+| `appsPolicyOldConsole` | `not found: either %s is not an app in this account, you are not a system operator, or this console predates app policies (deploy a console built from current main)` |
+| `appsPolicyChanged` | `the policy changed while this ran. Run it again` |
+| `appsSetEgressDone` | `%s instances may reach %s` |
+| `appsSetEgressCleared` | `%s instances have no egress allow` |
+| `appsSetSizeDone` | `%s instances run at %s unless an instance has its own size` |
+| `appsSetSizeCleared` | `%s instances run at the build's size unless an instance has its own size` |
+| `instancesSetSizeDone` | `%s runs at %s` |
+| `instancesSetSizeDefault` | `%s follows the app's size` |
+| `appsShowUsage` | `usage: carlos apps show <app> [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]` |
+| `appsShowEgress` / `appsShowDefault` / `appsShowOverride` | `egress allow %s` / `default size %s` / `size %s %s` |
+| `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.
+
+- [ ] **Step 1: Write the failing tests** (`apps_policy_test.go`, against an `httptest` console in the style of `TestAPISessionSetSwapPolicyWireShape`)
+
+```go
+func TestAppsSetPolicyRefusesClientSide(t *testing.T) {
+ // appsPolicyChangeFrom with --egress-allow "10.0.5.20:5432" -> error containing "has a port", no request sent
+ // "10.0.5.0/24" -> "is a range"; "8.8.8.8" -> "already reachable"; --size huge -> appsSetBadSize
+}
+func TestChangeAppPolicyWireShape(t *testing.T) {
+ // fake GET returns {"policy":{"v":1},"etag":""} -> the PUT carries If-None-Match: * and
+ // body {"egress_allow":["10.0.5.20","fd00:5::20"]} exactly (no default_size, no size keys)
+ // fake GET returns etag "e1" -> the PUT carries If-Match: e1
+ // --egress-allow "" -> body {"egress_allow":[]}; --size default -> {"default_size":""}
+ // PUT answers 412 -> error == appsPolicyChanged; GET answers bare 404 -> appsPolicyOldConsole
+}
+func TestInstancesSetSizeUsesThePolicyDoor(t *testing.T) {
+ // instancesSetRun(..., req nil, channel nil, size "xlarge") -> one PUT to /policy with
+ // {"size":{"host":"shop-a.example.com","value":"xlarge"}}; no updates-door request
+}
+func TestAppsShowPrintsThePolicy(t *testing.T) {
+ // reply {egress ["10.0.5.20"], default "large", sizes {shop-a: xlarge}, stale [shop-b]} ->
+ // stdout lines: "egress allow 10.0.5.20", "default size large",
+ // "size shop-a.example.com xlarge", and shop-b's line followed by appsShowStale
+ // empty policy -> "egress allow none", "default size the build's size"
+}
+func TestAppsSetWithoutPolicyFlagsSendsNoPolicyRequest(t *testing.T) {
+ // --swap hold alone -> no request to /policy (today's behaviour exactly)
+}
+```
+
+- [ ] **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 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)
+
+**Files:**
+- Test: `internal/provision/apppolicy_reconcile_test.go` (one test); no production change
+
+This plan changes no unit, slice or probe line. Its byte-identity surface is the registry row, both registry fingerprints, and the store door's answers for every other key.
+
+- [ ] **Step 1: Write the test**
+
+```go
+func TestNoPolicyLeavesRowsAndFingerprintsAsMain(t *testing.T) {
+ // fixture with two apps, records, no policy objects. Pass 1 mints.
+ // Every row: EgressAllow == "" && Size == "".
+ // Build a second registry by Upsert-ing RouteFor(rec, "/data", seed, false, nil) for each record
+ // (main's mint, which carries neither column) and assert Fingerprint and SnapshotFingerprint
+ // of the two registries are equal.
+ // Pass 2: SnapshotFingerprint unchanged (no converge wrote anything).
+}
+```
+
+- [ ] **Step 2: Run the proof set**
+
+```bash
+go test ./internal/provision -run 'TestNoPolicyLeavesRowsAndFingerprintsAsMain|TestReconcileMints|TestAnUntouchedFleetConvergesNothing'
+go test ./internal/registry -run 'TestPolicyColumns|TestFingerprintsSeeEachClaimOwnershipField|TestUpdateSettingsMovesSnapshotFingerprint'
+go test ./internal/edge -run 'TestStoreDoorRefusalsAreByteIdentical|TestStoreDoorMarksOnlyAGrantedAbsentPolicy'
+go test ./internal/fleetapi -run 'TestGetMissingIsNotExist|TestPolicyRefusalFromAnOldDoorIsNotAbsence'
+go test . -run 'TestAppsSetWithoutPolicyFlagsSendsNoPolicyRequest'
+```
+
+Expected: all PASS. `TestReconcileMints` compares the whole minted route to a literal, so it is the existing witness that a no-policy mint is main's.
+
+- [ ] **Step 3: Commit** `instance policy: nothing set is byte-identical (plan 1's part)`.
+
+---
+
+### Task 12: 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 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'`.
+- [ ] **Step 5: Commit** `census: instance-policy-1 overlay, proposed (#280)`.
+
+If another overlay lands on main first, load this one after it and say so in both files.
+
+---
+
+### Task 13: 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 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.
+- [ ] **Step 5: Commit** `instance policy: approved copy and rollout note`.
+
+---
+
+## 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.
+- **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
new file mode 100644
index 000000000..942629eb1
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md
@@ -0,0 +1,545 @@
+# Instance policy plan 2 of 3: egress allows (#328) Implementation Plan
+
+> **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:** 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.
+
+**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.
+
+## Global Constraints
+
+- Go; module `github.com/carlosframework/platform`. Leave `GOCACHE` at its default. Scratch under `$TMPDIR`. Socket tests (every `internal/instancectl` test that uses `serveOnSocket`): `TMPDIR=/tmp/g`.
+- 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`). Exception: the `rootrunner` tests run on the root runner.
+- Parallel tests: new tests in `internal/host`, `internal/console` call `t.Parallel()` unless they set env, cwd, umask, log output or package vars, reach `edge.SecureUnixListen`, or listen on TCP. No test depends on another having run. A new console or root test slower than a few seconds refreshes `.amadan/test-weights.tsv` with `.amadan/test-weights.sh`.
+- Cross-compile: CI builds darwin and windows. Anything reading cgroupfs or procfs in `internal/instancectl` or `internal/host` that is not already portable gets `//go:build linux` and a `!linux` stub.
+- Privileged tests: `//go:build linux && rootrunner`, run with `~/.cache/tmp/claude-1001/own/after236/root-runner.py --worktree <worktree> --pkg ./internal/host --run '<regex>'` with the sandbox off; obey `root-runner-contract.md` beside the script (no network beyond IMDS unless the test says so; clean up every unit, slice drop-in, interface and file it made). List each under "Root-runner acceptance: pending" in the task report.
+- 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.
+- `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).
+
+## Review Focus
+
+1. **systemd's read-back form differs from what root wrote** (order, `/32` dropped, v6 compressed differently). Expected: no rewrite churn every 30 s. Test: `TestEgressReadBackFormsAreOneSet` (Task 2).
+2. **Control service restarts during a store outage under claims, with an allow already on a slice.** Expected: the held allow is left as it is (not reset, not widened) and the host reports `unreadable`. Test: `TestEgressNoCacheAndSourceErrorLeavesTheSliceAlone` (Task 2).
+3. **A populated slice whose host root cannot name** (a long host's hashed instance id after a restart, before the registry seed). Expected: its allow is reset, never left open on a guess. Test: `TestEgressSweepResetsAnUnattributableSlice` (Task 4).
+4. **An operator removes one address while another already granted is now a fleet box's.** Expected: the revoke goes through; only added addresses are checked. Test: `TestEgressGrantChecksOnlyAddedAddresses` (Task 9).
+5. **A box with no non-loopback address, and a box whose procfs read fails.** Expected: `"addrs": []` for the first, `"addrs": null` (treated as lacking, grants refused naming it) for the second. Test: `TestEdgesFileAddrsEmptyAndUnreadable` (Task 8).
+
+---
+
+## File structure
+
+| File | Responsibility |
+|---|---|
+| `internal/host/egress_kernel_rootrunner_linux_test.go` (new) | Task 1: the kernel proof. |
+| `internal/instancectl/egress.go` (new) | `EgressAsserter`: desired, validate, read back, write, report, sweep, loop. |
+| `internal/instancectl/egress_cgroup_linux.go` / `egress_cgroup_other.go` (new) | Listing populated per-host slices (linux), stub elsewhere. |
+| `internal/instancectl/egress_test.go` (new) | Unit tests with `fakeCtl`. |
+| `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_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. |
+| `internal/status/snapshot.go`, `internal/host/agent.go` | Per-host egress in the box snapshot. |
+| `internal/console/apistatuslayout.go`, `apiinstances.go`, `status_instances.go`, `instances_cli.go`, `apimode.go` | Display. |
+
+---
+
+### Task 1: Kernel proof (spec §4.3), before anything is built on it
+
+**Files:**
+- Create: `internal/host/egress_kernel_rootrunner_linux_test.go`
+- Reuse: `plan14InstalledTenant` (`internal/host/plan14_installed_launch_root_linux_test.go:34-78`, the `?dial=` payload) and its dispatch in `TestMain` (`internal/host/plan14_generation_registry_root_linux_test.go:18-73`, argv `plan14-tenant`).
+
+**Interfaces:**
+- Produces: a recorded verdict, `revoke live: yes|no`, `open live: yes|no`, `combines with parent: yes|no`, `slice show is own entries only: yes|no`, in the task report and as a comment at the top of `internal/instancectl/egress.go` when Task 2 creates it. Tasks 2 to 4 branch on it as stated in Step 6.
+
+- [ ] **Step 1: Write `TestRootEgressKernelProof`** (`//go:build linux && rootrunner`). It fails rather than skips without root, systemd as PID 1 or cgroup v2. Setup, all removed in `t.Cleanup`:
+ - a dummy link `carlos-egr0` holding `10.0.5.20/32`, `10.0.5.21/32` and `fd00:5::20/128 nodad`; a TCP listener in the test process on each of `10.0.5.20:0`, `10.0.5.21:0` and `[fd00:5::20]:0`. The test reaches no other network (say so in its doc comment, per the contract).
+ - the deny line read from the embedded template: the `IPAddressDeny=` line of `unitsFS` `units/carlos-instance@.service`.
+ - `host := "egress-proof.example.com"`, `slice := instancectl.SliceName(host)`.
+ - `systemctl set-property --runtime carlos-instances.slice IPAddressAllow=10.0.5.21/32` (the resolver allow's stand-in).
+- [ ] **Step 2: Assertions, in order** (each reachability check is the tenant's `GET /?dial=<addr>`: 204 reachable, 403 not):
+ 1. `set-property --runtime <slice> IPAddressAllow=` then `IPAddressAllow=10.0.5.20/32 fd00:5::20/128` **before** the child exists; then `systemd-run --unit=carlos-egress-proof-a --slice=<slice> -p IPAddressDeny=<template list> -E PLAN14_TENANT_SOCKET=<scratch>/a.sock /proc/self/exe plan14-tenant`. `10.0.5.20`, `fd00:5::20` and `10.0.5.21` are reachable (written before start: the start-path order).
+ 2. `set-property --runtime <slice> IPAddressAllow=` (reset only) while the child runs: `10.0.5.20` and `fd00:5::20` become unreachable within 2 s (poll every 100 ms; **revoke live**); `10.0.5.21` stays reachable (**combines, not replaces**).
+ 3. Write `IPAddressAllow=` then `IPAddressAllow=10.0.5.20/32` again while it runs: reachable within 2 s (**open live**).
+ 4. `systemctl show -p IPAddressAllow --value carlos-egress-proof-a` equals its value before step 1 (the unit's own list is untouched, so `claimEffectiveUnit`, `internal/host/claimunit_effective.go:142-161`, which reads the unit's own property, is not tripped).
+ 5. `systemctl show -p IPAddressAllow --value <slice>` parses to exactly `{10.0.5.20/32}` (**own entries only**, the parent's `10.0.5.21` is not listed). Log the raw string so Task 2 pins its form.
+ 6. Isolation at the kernel: a second transient unit in `instancectl.SliceName("other-app.example.com")` and a third directly in `carlos-instances.slice` (the probe's place), both with the same deny, cannot reach `10.0.5.20` while step 3's allow is in force, and can reach `10.0.5.21`.
+- [ ] **Step 3: Cross-compile locally to prove it builds**
+
+Run: `GOOS=linux GOARCH=arm64 go test -c -tags rootrunner -o $TMPDIR/egress-kernel.test ./internal/host && rm $TMPDIR/egress-kernel.test`
+Expected: builds.
+
+- [ ] **Step 4: Run on the root runner** (sandbox off)
+
+Run: `~/.cache/tmp/claude-1001/own/after236/root-runner.py --worktree <worktree> --pkg ./internal/host --run '^TestRootEgressKernelProof$'`
+Expected: PASS, with the run id recorded in the task report.
+
+- [ ] **Step 5: Record the verdict** as the comment Task 2 places in `egress.go`.
+- [ ] **Step 6: What later tasks do on each answer** (decided now, so no task waits on a re-plan):
+ - Revoke **not** live: Task 2's `Assert`, after any write that removes an address the slice held, runs `systemctl stop <slice>` (stopping every unit in it; the activator wakes the host again on the next request under the new list), logs it, and reports `State: "failing"` with `Detail: "restarted to close <addr>"` until the next read-back. Task 13's revoke witness then asserts the instance was stopped and the address is unreachable after its next wake.
+ - Open **not** live: a grant takes effect at the next start; `Assert` reports `State: "pending"` for a host whose slice is populated and whose held list lacks a desired address after a successful write. Add `"pending"` to Task 2's states and to Task 17's copy.
+ - Does **not** combine (the slice allow replaces the parent's): stop. The design is unsound on this systemd; report to the controller before Task 2.
+ - Slice `show` lists inherited entries: Task 2's read-back subtracts `InstancesSlice`'s own read-back before comparing.
+- [ ] **Step 7: Commit** `git add internal/host/egress_kernel_rootrunner_linux_test.go` with message `egress: kernel proof that a slice allow opens, closes and combines live (#328)`.
+
+---
+
+### Task 2: The root asserter (validation, read-back, write, report)
+
+**Files:**
+- Create: `internal/instancectl/egress.go`, `internal/instancectl/egress_test.go`
+- Modify: `internal/instancectl/localaddrs.go:120-127` (keep `isLocal`; add the exported reader below)
+
+**Interfaces:**
+- Consumes: `egressaddr.Parse(string) (netip.Addr, error)`, `egressaddr.Allowed(netip.Addr) error` (plan 1); `isLocal` (`localaddrs.go:120`); `SliceName` (`names.go:109`); `parseShow` (`server.go:537`).
+- Produces (later tasks use exactly these):
+
+```go
+// EgressPolicy is one host's desired allow as a source reads it. Root
+// validates every entry; a source never pre-filters.
+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)
+
+const (
+ EgressFailing = "failing" // desired and held differ after a write
+ EgressUnreadable = "unreadable" // the policy could not be read or was refused
+ EgressOtherIncarnation = "other_incarnation" // policy app_id is not the current app's
+ EgressNotNested = "not_nested" // pre-claim box not wholly nested
+)
+
+// EgressReport is one host's read-back (spec §4.4).
+type EgressReport struct {
+ Allowed []string `json:"allowed,omitempty"` // what the slice holds, as bare addresses, sorted
+ Refused []string `json:"refused,omitempty"` // "<entry>: <reason>"
+ State string `json:"state,omitempty"` // "" when held == desired and the policy was read
+ Detail string `json:"detail,omitempty"`
+}
+
+type EgressAsserter struct {
+ Systemctl func(context.Context, ...string) (string, error)
+ Source EgressSource
+ LocalAddrs func() ([]netip.Prefix, error)
+ 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,
+ // reports map[host]EgressReport, names map[iid]host
+}
+func (e *EgressAsserter) Assert(ctx context.Context, host string) error
+func (e *EgressAsserter) Reports() map[string]EgressReport // a copy
+func LocalAddresses() ([]netip.Prefix, error) // localPrefixes(fibTriePath, ifInet6Path)
+```
+
+A nil `*EgressAsserter` is valid: `Assert` returns nil and does nothing, `Reports` returns nil. That is how `Run` (no host wiring) and every existing test stay byte-identical.
+
+- [ ] **Step 1: Write the failing tests** in `egress_test.go` (fake `Systemctl` recording calls, as `fakeCtl` in `server_test.go:20-90`; a `show -p IPAddressAllow --value <slice>` answer the fake updates when it sees a `set-property` on that slice):
+ - `TestEgressNoPolicyWritesNothing`: source returns `EgressPolicy{}`, slice shows `""`; the only call is the `show`; report is `{}`.
+ - `TestEgressWritesResetThenList`: source `Allow: {"fd00:5::20", "10.0.5.20", "10.0.5.20"}`; calls are `set-property --runtime carlos-instances-shop-a.example.com.slice IPAddressAllow= IPAddressAllow=10.0.5.20/32 fd00:5::20/128` (one invocation, reset first, as `allowResolvers` at `hardening.go:254-257`); report `Allowed: ["10.0.5.20", "fd00:5::20"]`.
+ - `TestEgressRefusesEachBadEntryWithItsReason`: `Allow: {"10.0.5.20", "127.0.0.1", "8.8.8.8", "10.0.5.20:5432", "::ffff:10.0.5.21"}`; written `10.0.5.20/32 10.0.5.21/32` (mapped v4 unmapped to `/32`); `Refused` names `127.0.0.1`, `8.8.8.8`, `10.0.5.20:5432` with `egressaddr`'s reasons.
+ - `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`.
+ - `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 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)`.
+
+---
+
+### Task 3: Every start path asserts before `systemctl start`
+
+**Files:**
+- Modify: `internal/instancectl/server.go` (`Server` struct at 30-153: add `Egress *EgressAsserter`; `handleStart` 461-535, after the tier block at ~503-511; `handleSeed` 574-638, after `setTier` at ~585-588), `gen.go` (`startGen` 120-187, after the tier block at 161-166), `cmd.go` (`handleCmd` 41-115, after the tier block at 84-90), `claimunit.go` (`ClaimUnits` struct 25-37: add `Egress *EgressAsserter`; `start` 136-241, after `sliceTier` at 167-169)
+- Test: `internal/instancectl/egress_startpaths_test.go` (new)
+
+**Interfaces:**
+- Consumes: `(*EgressAsserter).Assert` (Task 2).
+- Produces: `Server.Egress`, `ClaimUnits.Egress` (both `*EgressAsserter`, nil = off).
+
+The populated-slice skips for the tier (sidecar with exe, cmd, gen) stay exactly as they are; the egress assert runs whether or not the tier was skipped. On an `Assert` error the start is refused as a `set-property` failure is today (`502`, body `egress: <err>`; for gen through `fail`, before the start; for the claim path, `start` returns the error before `systemctl start`). The run path's `#292` cancellation check stays after it.
+
+- [ ] **Step 1: Write the failing tests** (fake `Systemctl`; `newServer(t, ctl, func(s *Server){ s.Egress = ... })`, source returning `Allow: {"10.0.5.20"}`): for each of run, sidecar, seed, gen, cmd, `TestEgressAssertedBeforeStart/<role>` asserts the `set-property ... IPAddressAllow=` call comes after the tier call (or its skip) and before `start`; `TestEgressFailureRefusesTheStart/<role>` asserts a fake failing the egress `set-property` yields 502 and no `start` call. `TestClaimUnitStartAssertsEgress` does the same through `ClaimUnits.Start` (fixture as in `claimunit_test.go`). `TestStartTranscriptsUnchangedWithNoPolicy/<role>`: with `Egress` set and an empty source, the transcript equals the transcript with `Egress` nil except for the one `show -p IPAddressAllow` read.
+- [ ] **Step 2: Run** `TMPDIR=/tmp/g go test ./internal/instancectl -run 'TestEgressAsserted|TestEgressFailureRefuses|TestClaimUnitStartAssertsEgress|TestStartTranscriptsUnchanged' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Implement** the five calls and the two fields. Update the `Server` doc comment (`server.go:30-35`) to name the egress allow among root's jobs and keep the request rule.
+- [ ] **Step 4: Run** Step 2's command and the existing `TMPDIR=/tmp/g go test ./internal/instancectl -run 'TestStart|TestSeed|TestGen|TestCmd|TestClaimUnit' -count=1`. Expected: PASS.
+- [ ] **Step 5: Commit** `instancectl: assert the egress allow on every start path (#328)`.
+
+---
+
+### Task 4: The 30 s loop, control-service start, `POST /egress` and `GET /egress`
+
+**Files:**
+- Modify: `internal/instancectl/egress.go`, `server.go` (`Handler` 306-333; `runWithClaim` 166-222: start the loop beside `go s.diskLoop(ctx)` at 219; `Run`/`RunWithRegistry` 160-164 and `claim.go` `RunWithClaim` take the asserter), `client.go` (new verbs)
+- Create: `internal/instancectl/egress_cgroup_linux.go`, `egress_cgroup_other.go`
+- Test: `internal/instancectl/egress_test.go`, `client_test.go`
+
+**Interfaces:**
+- Produces:
+
+```go
+func (e *EgressAsserter) Sweep(ctx context.Context) // one pass over populated per-host slices
+func (e *EgressAsserter) Loop(ctx context.Context, every time.Duration) // Sweep at once, then every `every`
+func populatedHostSlices(cgroupRoot string) ([]string, error) // slice unit names under carlos.slice/carlos-instances.slice with "populated 1"
+// routes (not wrapped in claimAwareLegacy: served with claims on too)
+// POST /egress?host=<host> -> 204, or 502 with the error; 400 on a bad host
+// GET /egress -> 200 EgressStatus
+type EgressStatus struct { Hosts map[string]EgressReport `json:"hosts"` }
+func (c *Client) Egress(ctx context.Context, host string) error
+func (c *Client) EgressStatus(ctx context.Context) (EgressStatus, error)
+// signatures change (callers only in internal/host/claimstop_run.go):
+func RunWithRegistry(args []string, hostApp func(string) (string, string, error), egress *EgressAsserter)
+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.
+
+- [ ] **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 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 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)`.
+
+---
+
+### Task 5: Pre-claim source and the wholly-nested rail
+
+**Files:**
+- Create: `internal/host/egress_source.go`, `internal/host/egress_source_test.go`
+- Modify: `internal/host/claimstop_run.go` (`RunInstanceControl` 19-37: build and pass the asserter on the `!hostwrite.Enabled` branch)
+
+**Interfaces:**
+- Consumes: `registry.Route.EgressAllow` (plan 1); `activator.ActivatorManaged`, `activator.Classify`, `activator.LayoutInputs` (`internal/activator/layout.go:63-110`); `layout.JournalPresent` (`internal/layout/journal.go:308`); `openServiceRegistryReadOnly` (as `instanceRegistryHostApp`, `claimstop_run.go:121-140`).
+- 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.
+func boxWhollyNested(rows []registry.Route, dataDir string) (ok bool, reason string)
+func registryEgressSource(dataDir string) instancectl.EgressSource
+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
+```
+
+`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.
+
+- [ ] **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 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 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
+
+**Files:**
+- 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`).
+- Produces:
+
+```go
+func storeEgressSource(hostApp func(string) (string, string, error), store release.Store) instancectl.EgressSource
+const claimEgressRefresh = 5 * time.Minute
+```
+
+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`.
+
+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.
+
+- [ ] **Step 1: Write the failing tests** (memory store, `hostApp` stub):
+ - `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).
+ - `TestStoreEgressSourceOtherIncarnation` (table: mismatched, empty, admission absent, admission `ErrIdentity`).
+ - `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)`.
+
+---
+
+### Task 7: The agent's prompt `POST /egress`
+
+**Files:**
+- Create: `internal/host/egress_nudge.go`, `egress_nudge_test.go`
+- 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`.
+- 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.
+
+- [ ] **Step 1: Failing tests:** `TestEgressNudgeOnlyChangedHosts`, `TestEgressNudgeRevokeToEmpty`, `TestEgressNudgeRetriesAfterAFailure`.
+- [ ] **Step 2: Run** `go test ./internal/host -run 'TestEgressNudge' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run** Step 2's command. Expected: PASS.
+- [ ] **Step 5: Commit** `host: the agent nudges root after an egress column changes (#328)`.
+
+---
+
+### Task 8: Each box publishes `addrs` in its edges file, inside the write gate
+
+**Files:**
+- Modify: `internal/host/edgesfile.go` (`edgesFile` 229-359: add `Addrs`; `edgesFileWriteClaims` 420-702: fill it before `gateCopy` at ~678), `internal/edgesfile/edgesfile.go` (reader `File` 68-75: add `Addrs`)
+- Create: `internal/edgesfile/addrs.go`, `addrs_test.go`
+- Test: `internal/host/edgesfile_test.go`
+
+**Interfaces:**
+- Consumes: `instancectl.LocalAddresses` (Task 2).
+- Produces:
+
+```go
+// internal/host: writer field, never omitted. nil marshals "null" (the read failed),
+// an empty non-nil slice "[]" (nothing to publish).
+Addrs []string `json:"addrs"`
+func boxAddrs(read func() ([]netip.Prefix, error)) []string // non-loopback LOCAL entries; /32 and /128 as bare addresses, wider prefixes as CIDR; sorted
+
+// internal/edgesfile
+type AddrInventory struct {
+ Boxes map[string][]netip.Prefix // box -> published addrs (bare address = full-length prefix)
+ Unreadable []string // keys that could not be read or parsed
+ Missing []string // boxes whose file has no addrs (absent or null: an older box, or a failed read)
+}
+func ReadAddrs(ctx context.Context, s release.Store) (AddrInventory, error) // error: the listing failed
+func (inv AddrInventory) Holder(a netip.Addr) (box string, ok bool)
+```
+
+`addrs` is in `f` before `gateCopy` is marshalled, so an address change rewrites the file on the next due pass. Bucketless fleet boxes (`edgesFileLoop` skips them, `edgesfile.go:802-807`) publish nothing; Task 9 names them. Plan 3's `mem_total_mib` field goes beside this one in the gate.
+
+- [ ] **Step 1: Failing tests:** `TestEdgesFileAddrsInTheGate` (same routes, different `boxAddrs` → a second write); `TestEdgesFileAddrsEmptyAndUnreadable` (Review Focus 5); `TestBoxAddrsDropsLoopback` (fib_trie fixture from `hardening_test.go:228-292`'s style: `127.0.0.0/8`, `127.0.0.1/32`, `10.0.1.5/32`, `::1/128`, `fe80::1/128` → `["10.0.1.5", "fe80::1"]`); `TestReadAddrsInventory` (one file with addrs, one without, one null, one unreadable, one unversioned); `TestReadAddrsHolderMatchesPrefixes`.
+- [ ] **Step 2: Run** `go test ./internal/edgesfile -run 'TestReadAddrs' -count=1 && go test ./internal/host -run 'TestEdgesFileAddrs|TestBoxAddrs' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run** Step 2's commands and `go test ./internal/host -run 'TestEdgesFile' -count=1`. Expected: PASS.
+- [ ] **Step 5: Commit** `edges: each box publishes its addresses as addrs, inside the write gate (#328)`.
+
+---
+
+### Task 9: The grant-time fleet check fills `not_checked`
+
+**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.
+
+**Interfaces:**
+- Consumes: `edgesfile.ReadAddrs`, `AddrInventory.Holder` (Task 8); `s.systemBoxes(ctx, false)` (`apisystemupdate.go:269`); `status.BoxStale`; `doorRefusal` (`domainattach.go:74`).
+- Produces:
+
+```go
+// egressGrantCheck checks the addresses a grant adds (new minus old) against
+// every readable edges file, whatever its age. notChecked: labels of
+// non-stale boxes in systemBoxes that publish no edges file.
+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).
+
+- [ ] **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.
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run** Step 2's command. Expected: PASS.
+- [ ] **Step 5: Commit** `console: check an egress grant against every box's published addresses (#328)`.
+
+---
+
+### Task 10: The console's report (timer, edges change, alert, `!`)
+
+**Files:**
+- Modify: `internal/console/egressfleet.go`, `egressfleet_test.go`, `claim_workers.go:5-33` (register `StartEgressReport`), `claim_invariant_fixtures_test.go` (its fixture: `claimNonApp("operator alert mail; writes no store object")`), `cmd/console/main.go:497-525` (`go srv.RunClaimWorker(context.Background(), "StartEgressReport")`)
+
+**Interfaces:**
+- Consumes: `apppolicy.Prefix`, `apppolicy.Get` (plan 1); `edgesfile.ReadAddrs`; `alerting.Page`, `alerting.LevelWarning`; the `noCapacityAlerts` due/failed pattern (`nocapacity.go:59-95`).
+- Produces:
+
+```go
+func (s *Server) StartEgressReport(ctx context.Context)
+func (s *Server) egressReportPass(ctx context.Context) error
+func (s *Server) egressConflicts() map[string]string // address -> box, from the last pass; nil before one
+var pageEgressConflict = alerting.Page
+const egressReportEvery = 15 * time.Minute
+const egressEdgesPoll = time.Minute
+const egressAlertEvery = 24 * time.Hour
+```
+
+`StartEgressReport` returns at once when `s.cfg.Releases` is nil. It runs a pass at start and every 15 minutes. Between passes, and only while the last pass found at least one policy with a non-empty allow, it lists `control/edges/` with `ListMod` (fallback `List`) every minute and runs a pass when the key and modified-time set changes (decision: no listing at all on a deployment with no allows). A pass lists `apppolicy.Prefix`, reads each policy, and for each allowed address that `Holder` finds raises one alert per (account, app, address) per `egressAlertEvery` per console process, slot given back on a failed send. The allow stays in force. Draft alert copy (Task 17): subject `Egress allow <addr> is now a fleet box's address`; body `App <app> in account <account> may reach <addr>, and box <box> now holds that address. The allow is still in force. Remove it with carlos apps set <app> --egress-allow, or move the address off the box.`
+
+- [ ] **Step 1: Failing tests:** `TestEgressReportAlertsOnceAndKeepsTheAllow` (an address that a later edges file lists: one page across two passes, policy object unchanged, `egressConflicts()` names it), `TestEgressReportRetriesAFailedSend`, `TestEgressReportPollsOnlyWithAllows` (fake store counts `List` calls: zero edges listings between passes with no allows), `TestEgressReportRunsOnEdgesChange`.
+- [ ] **Step 2: Run** `go test ./internal/console -run 'TestEgressReport|TestClaimConsoleMutationInvariant' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Implement.**
+- [ ] **Step 4: Run** Step 2's command. Expected: PASS.
+- [ ] **Step 5: Commit** `console: re-check egress allows against fleet addresses and alert (#328)`.
+
+---
+
+### Task 11: Per-host read-back in the box status snapshot
+
+**Files:**
+- Modify: `internal/status/snapshot.go` (`Probes` 41-195: `Egress`; `BoxMetrics` 469-637: `Egress`; `CollectBox` 871-1105: the "anything known" condition at ~1043-1048 and the copy at ~1078), `internal/host/agent.go:3793-3870` (status tick)
+- 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).
+- Produces:
+
+```go
+// internal/status
+type HostEgress struct {
+ Allowed []string `json:"allowed,omitempty"`
+ Refused []string `json:"refused,omitempty"`
+ State string `json:"state,omitempty"`
+ Detail string `json:"detail,omitempty"`
+}
+// 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
+```
+
+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 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.
+- [ ] **Step 5: Commit** `status: each box reports its instances' egress read-back (#328)`.
+
+---
+
+### 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)
+- Test: `status_instances_test.go:67` (heading), `instances_cli_test.go`, `internal/console/apistatuslayout_test.go`, `internal/console/apiinstances_test.go`
+
+**Interfaces:**
+- Consumes: `status.BoxMetrics.Egress` (Task 11); `s.egressConflicts()` (Task 10).
+- Produces:
+
+```go
+// wire, console and CLI (declared on each side, the repo's convention)
+type apiEgress struct {
+ Allowed []string `json:"allowed,omitempty"`
+ Conflicts []string `json:"conflicts,omitempty"` // allowed addresses a fleet box now holds
+ Refused []string `json:"refused,omitempty"`
+ State string `json:"state,omitempty"`
+ Detail string `json:"detail,omitempty"`
+}
+// apiStatusLayoutRow.Egress, apiInstanceRecord.Egress: *apiEgress `json:"egress,omitempty"`
+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 `-`.
+
+- [ ] **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 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)`.
+
+---
+
+### Task 13: Production-wired witnesses per start path, and revoke while running
+
+**Files:**
+- Create: `internal/host/egress_startpaths_rootrunner_linux_test.go`
+- 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)`).
+
+- [ ] **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 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 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)`.
+
+---
+
+### Task 14: Isolation: another app's instance and the probe cannot reach the allowed address
+
+**Files:**
+- Modify: `internal/host/egress_startpaths_rootrunner_linux_test.go`
+
+- [ ] **Step 1: Write `TestRootEgressIsolation`**: the Task 13 server with two nested rows on one box, `shop-a.example.com` (app A, `EgressAllow: "10.0.5.20"`) and `shop-b.example.com` (app B, none), both started through `/start`. A dials `10.0.5.20` (204); B dials it (403); the real probe unit (`carlos-instance-probe.service` from `unitsFS`, started by `Server.RunProbe` with its fixtures) leaves its report `Ready` unchanged, and a transient unit in `carlos-instances.slice` carrying the probe template's `IPAddressDeny=` line dials it (403). `systemctl show -p IPAddressAllow --value carlos-instances.slice` equals the resolver allow only.
+- [ ] **Step 2: Cross-compile** as Task 13 Step 5.
+- [ ] **Step 3: Run on the root runner** with `--run '^TestRootEgressIsolation$'`. Expected: PASS.
+- [ ] **Step 4: Commit** `host: egress isolation witness: other apps and the probe stay fenced (#328)`.
+
+---
+
+### Task 15: The byte-identical proof
+
+**Files:**
+- Modify: `internal/host/egress_startpaths_rootrunner_linux_test.go`, `internal/instancectl/egress_startpaths_test.go`
+
+- [ ] **Step 1: Write `TestRootEgressNoPolicyByteIdentical`**: start every role with no column value and with `Egress` nil, then with an empty source; for each, collect `systemctl show` of the unit and the slice (all properties, minus `InvocationID`, timestamps, `MainPID`, `ControlGroupId`, memory and CPU accounting counters) and the file list and bytes of `/run/systemd/system.control/<slice>.d` and `/run/systemd/system.control/carlos-instances.slice.d`; the two runs are equal, and no file named for `IPAddressAllow` exists under the host's slice directory.
+- [ ] **Step 2: Extend** `TestStartTranscriptsUnchangedWithNoPolicy` (Task 3) to the claim path and to a 3-sweep `Loop` run: no `set-property` call at all.
+- [ ] **Step 3: Run** `TMPDIR=/tmp/g go test ./internal/instancectl -run 'TestStartTranscriptsUnchanged' -count=1` (PASS), cross-compile, then the root runner with `--run '^TestRootEgressNoPolicyByteIdentical$'` (PASS).
+- [ ] **Step 4: Commit** `egress: prove nothing changes on an instance with no policy (#328)`.
+
+---
+
+### Task 16: Census overlay
+
+**Files:**
+- Create: `internal/provision/testdata/plan14-root-reads-instance-egress.tsv`, `docs/superpowers/notes/census-proposal-instance-egress.md`
+- 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 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.
+
+---
+
+### Task 17: Copy review of every person-facing string
+
+**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 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.
+- [ ] **Step 5: Commit** `copy: approved egress strings (#328)`.
+
+---
+
+## 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.
+- 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
new file mode 100644
index 000000000..0e5df1e11
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md
@@ -0,0 +1,589 @@
+# Instance sizes (#329) Implementation Plan (plan 3 of 3)
+
+> **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:** 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.
+
+**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.
+
+## Global Constraints
+
+- Go; module `github.com/carlosframework/platform`. Leave `GOCACHE` at its default. Scratch under `$TMPDIR`. Socket tests: `TMPDIR=/tmp/g`.
+- 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`).
+- Parallel tests: new tests in `internal/host`, `internal/activator`, `internal/console` 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 or root test slower than a few seconds refreshes `.amadan/test-weights.tsv` with `.amadan/test-weights.sh`.
+- Cross-compile: CI builds darwin and windows. Linux-only syscalls in `internal/host` or `internal/instancectl` need `//go:build linux` and a `!linux` stub.
+- Root code changes end with a census overlay (Task 13), loaded after `plan14-root-reads-ci-speed-activator` and after plan 2's overlay.
+- Production-wired witnesses: claim and root behaviour is tested through the real control service, units and wiring, not fixtures or sleeps.
+- Privileged tests: `//go:build linux && rootrunner`, run with `~/.cache/tmp/claude-1001/own/after236/root-runner.py --worktree DIR --pkg ./internal/x --run REGEX` with the sandbox off; read `root-runner-contract.md` beside the script.
+- 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).
+- The size table, verbatim from spec §5.1 (`MemoryHigh` is always `HighOf(max) = max / 10 * 9`):
+
+ | size | MemoryMax (bytes) | MemoryHigh | CPUQuota | CPUWeight | IOWeight |
+ |---|---|---|---|---|---|
+ | small | 268435456 | 241591905 | 50% | 50 | 50 |
+ | standard | 536870912 | 483183819 | 100% | 100 | 100 |
+ | large | 1073741824 | 966367638 | 200% | 100 | 100 |
+ | xlarge | 2147483648 | 1932735276 | 400% | 200 | 200 |
+
+## Consumed from plan 1 (pinned in the brief)
+
+- `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`.
+
+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.
+
+## Sitting beside 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:
+- `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).
+
+## Review Focus
+
+1. A generation number reused after a cold start must not inherit a previous generation unit's `set-property --runtime` memory (systemd keeps it in `/run/systemd/system.control/<unit>.d/` until reboot, and it outranks the generation drop-in). Test in Task 5.
+2. A `size` column holding a name this binary does not know (a newer console, a hand edit) starts the instance at the build's tier and logs once; it never refuses a spawn. Test in Task 3.
+3. A live change for a host that is asleep, waking, a worker, or flat sends nothing and leaves the change for the next start (shown as pending). Test in Task 6.
+4. Setting a size back to the one in force while a memory shrink is pending sends only the CPU and IO write needed and clears the pending mark. Test in Task 6.
+5. A `/size` naming a generation that is not active is refused before any write, so no control drop-in is left for a unit that is not running. Test in Task 5.
+
+---
+
+### Task 1: The size table drives every cgroup number
+
+**Files:**
+- Modify: `internal/cgroup/cgroup.go:84-123` (`TierDefault`, `TierChildren`, `SharesOf`)
+- Modify: `internal/cgroup/gen.go:61-77` (`ValidParentLimit`)
+- Modify: `internal/cgroup/strict_linux.go:216` (`prepareStrict`), `internal/cgroup/strict_gen_linux.go:162` (`PrepareGen`)
+- Test: `internal/cgroup/cgroup_test.go` (beside `TestTiers`, ~389), `internal/cgroup/gen_test.go`, `internal/cgroup/strict_linux_test.go:96`, `internal/cgroup/strict_gen_linux_test.go`
+
+**Interfaces:**
+- Consumes: plan 1's `Size`, `Sizes`, `SizeByName`, `SizeOf`.
+- Produces: `TierDefault == mustSize("standard").Limit`, `TierChildren == mustSize("large").Limit` (names kept for existing callers); `SharesOf(l Limit) Shares` = `SizeOf(l).Shares`, else standard's shares; `ValidParentLimit(l Limit) bool` = one size or the sum of any two; `func MaxLimit(a, b Limit) Limit` (the larger `MemoryMax`).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestSizeTableIsTheSpecsBytes(t *testing.T) {
+ want := map[string]struct{ max, high int64; sh Shares }{
+ "small": {268435456, 241591905, Shares{CPUWeight: 50, CPUQuotaPercent: 50, IOWeight: 50}},
+ "standard": {536870912, 483183819, Shares{CPUWeight: 100, CPUQuotaPercent: 100, IOWeight: 100}},
+ "large": {1073741824, 966367638, Shares{CPUWeight: 100, CPUQuotaPercent: 200, IOWeight: 100}},
+ "xlarge": {2147483648, 1932735276, Shares{CPUWeight: 200, CPUQuotaPercent: 400, IOWeight: 200}},
+ }
+ // names in order small, standard, large, xlarge; each row's MemoryMax,
+ // HighOf(MemoryMax), Shares and SharesOf(row.Limit) equal want[name];
+ // SizeOf(row.Limit) returns the row.
+}
+func TestTiersAreTheirSizesByteForByte(t *testing.T) // TierDefault == standard.Limit, TierChildren == large.Limit, SharesOf(TierChildren) == {100,200,100}, SharesOf(TierDefault) == {100,100,100}
+func TestSharesOfANonSizeIsStandards(t *testing.T) // SharesOf(Limit{MemoryMax: 7}) == standard's shares
+func TestValidParentLimitIsOneSizeOrTwo(t *testing.T)
+// accepts every row and every pairwise sum: 256, 512, 768, 1024, 1280, 1536, 2048, 2304, 2560, 3072, 4096 MiB
+// refuses 0, 7, 384 MiB, 3584 MiB, 6144 MiB
+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")`.
+
+- [ ] **Step 2: Run them to see them fail**
+
+Run: `go test ./internal/cgroup/ -run 'TestSizeTable|TestTiersAre|TestSharesOf|TestValidParentLimit|TestMaxLimit|TestStrictRejects|TestPrepareGen'`
+Expected: FAIL (small and xlarge refused by `ValidParentLimit`, strict validators; `MaxLimit` undefined).
+
+- [ ] **Step 3: Implement**
+
+If plan 1 left the numbers in `Sizes` as placeholders, fill them from the Global Constraints table. Define `TierDefault`/`TierChildren` from the table (an unexported `mustSize(name) Size` that panics at init on a missing row). `SharesOf` looks up `SizeOf(l)`. `ValidParentLimit` loops over `Sizes` twice. Strict: replace `limit != TierDefault && limit != TierChildren` with `_, ok := SizeOf(limit); !ok` in both places. `applyLimit` and `HighOf` are unchanged.
+
+- [ ] **Step 4: Run them to see them pass, plus the package's existing tier tests**
+
+Run: `go test ./internal/cgroup/ -run 'Size|Tier|Shares|ValidParent|MaxLimit|Strict|PrepareGen|StartChild'`
+Expected: PASS.
+
+- [ ] **Step 5: Commit** (`cgroup: the size table drives tiers, shares and every limit validator`)
+
+### Task 2: Root's validators take the table
+
+**Files:**
+- Modify: `internal/instancectl/server.go:381-392` (`parseRequest`'s `v.tier` switch)
+- Modify: `internal/instancectl/claimunit.go:67-73` (`claimTier`)
+- Modify: `internal/host/claimlaunch_root.go:163-165` (`claimLaunchTier`)
+- Modify: `internal/host/claimroleassembly.go:119-133` (`claimIsolatedPaths`)
+- Test: `internal/instancectl/server_test.go` (beside `TestStartSetsTheTierThenStartsThenReadsTheInvocation`, ~152), `internal/instancectl/server_gen_test.go`, `internal/instancectl/claimunit_test.go`, `internal/host/claimlaunch_root_test.go`, `internal/host/claimlaunch_transport_test.go:205-220`
+
+**Interfaces:**
+- Consumes: `cgroup.SizeOf`, `cgroup.ValidParentLimit` (Task 1).
+- Produces: each validator accepts exactly the four sizes' `MemoryMax`; `/parent` accepts one size or the sum of two (already via `ValidParentLimit`).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestStartTakesEverySize(t *testing.T) {
+ // table over the four sizes: POST /start?host=bill-2.example.com&role=run&memory_max=<max>
+ // first systemctl line is exactly:
+ // small: "set-property --runtime carlos-instances-bill\\x2d2.example.com.slice MemoryMax=268435456 MemoryHigh=241591905 MemorySwapMax=268435456 CPUWeight=50 CPUQuota=50% IOWeight=50"
+ // xlarge: "... MemoryMax=2147483648 MemoryHigh=1932735276 MemorySwapMax=2147483648 CPUWeight=200 CPUQuota=400% IOWeight=200"
+ // standard and large: the lines TestStartSetsTheTier... and TestStartReportsSystemctlFailureAs502 already pin
+}
+```
+Add to `TestStartRefusesBadInputsWithoutCallingSystemctl`: `memory_max=805306368` (768 MiB, a sum, not a size) is 400. Add a `/parent?memory_max=805306368` case that is 204, and `/parent?memory_max=402653184` (384 MiB) that is 400. In `claimunit_test.go`: `claimTier` accepts the four and refuses 805306368. In `claimlaunch_root_test.go`: `claimLaunchTier` likewise. In `claimlaunch_transport_test.go`'s `claimIsolatedPaths` table: small and xlarge succeed with `MemoryMax` carried through; `"not a tier"` (7) and a new `"a sum"` (805306368) refuse with `hostbox.ErrRoleLaunchShape`.
+
+- [ ] **Step 2: Run them to see them fail**
+
+Run: `TMPDIR=/tmp/g go test ./internal/instancectl/ -run 'TestStartTakesEverySize|TestStartRefusesBadInputs|Parent|ClaimTier'` and `go test ./internal/host/ -run 'ClaimLaunchTier|ClaimIsolatedPaths'`
+Expected: FAIL on small and xlarge.
+
+- [ ] **Step 3: Implement**
+
+Each validator becomes `_, ok := cgroup.SizeOf(cgroup.Limit{MemoryMax: n})`. `parseRequest` keeps its error text `memory_max %d is not a platform tier`.
+
+- [ ] **Step 4: Run them to see them pass** (same commands). Expected: PASS.
+
+- [ ] **Step 5: Commit** (`instancectl, host: every tier validator takes the size table`)
+
+### Task 3: The effective size is the box's tier source
+
+**Files:**
+- Modify: `internal/activator/backend_exec.go:417-423` (`tier`), `internal/activator/genbackend.go:279-286` (`GenTier`)
+- Create: `internal/activator/size.go` (`SizedLimit`, `EffectiveSize`)
+- Modify: `internal/host/claimunit_inputs.go:74` (the bound unit's `Limit: cgroup.TierDefault`)
+- Test: `internal/activator/backend_exec_test.go` (beside `TestExecBackendTierSeam`, ~29), `internal/activator/genbackend_test.go` (~245), `internal/activator/claim_snapshot_test.go` (or the file holding the snapshot's tests), `internal/host/claimunit_inputs_test.go` (create if absent)
+
+**Interfaces:**
+- Consumes: `registry.Route.Size` (plan 1), `cgroup.SizeByName`.
+- Produces:
+ - `func SizedLimit(rt registry.Route, fallback cgroup.Limit) cgroup.Limit`: `SizeByName(rt.Size).Limit` when `rt.Size` names a row, else `fallback`. An unknown non-empty name logs once per host and value (`activator: %s: size %q is not one this box knows; using the build's tier`) and returns `fallback`.
+ - `func (b *ExecBackend) EffectiveSize(rt registry.Route, build string) cgroup.Size`: `SizeOf(b.GenTier(rt, build))`, standard if no row matches.
+ - `tier(rt)` = `SizedLimit(rt, <Tier seam or TierDefault>)`; `GenTier(rt, build)` = `SizedLimit(rt, <TierOf seam, else tier>)`. `SplitBackend.GenTier` delegates unchanged.
+
+Every caller of `b.tier(rt)` and `GenTier` (run, sidecar, seed, cmd, parent via captured `Gen.Tier`, generation, `claim_snapshot.go:229`) now carries the effective size with no change of its own. `memtier.go` stays the build's tier.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestTierSeamPrefersTheSizeColumn(t *testing.T) {
+ t.Parallel()
+ b := &ExecBackend{Tier: func(registry.Route) cgroup.Limit { return cgroup.TierChildren }}
+ // rt.Size "" -> TierChildren; "small" -> 256 MiB; "xlarge" -> 2 GiB;
+ // "xxlarge" -> TierChildren (and a log line)
+}
+func TestGenTierPrefersTheSizeColumnOverTheBuild(t *testing.T) // TierOf returns TierDefault for v2; rt.Size "large" -> TierChildren; "" -> TierDefault
+func TestClaimSnapshotCarriesTheEffectiveSize(t *testing.T) // out.Limit for rt.Size "small" is 256 MiB, for "" is the Tier seam's
+func TestClaimBoundSnapshotCarriesTheSizeColumn(t *testing.T) // claimBoundSnapshot: rt.Size "xlarge" -> 2 GiB; "" -> TierDefault (unchanged)
+```
+
+- [ ] **Step 2: Run them to see them fail**
+
+Run: `go test ./internal/activator/ -run 'TierSeamPrefers|GenTierPrefers|ClaimSnapshotCarries'` and `go test ./internal/host/ -run ClaimBoundSnapshotCarries`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement** `SizedLimit`, `EffectiveSize`, and the two seam changes; in `claimBoundSnapshot` replace `cgroup.TierDefault` with `activator.SizedLimit(rt, cgroup.TierDefault)`. The bound path's fallback stays `TierDefault`, not the build's tier: that keeps it byte-identical with no policy.
+
+- [ ] **Step 4: Run them to see them pass**, plus `go test ./internal/activator/ -run 'Tier'` and `go test ./internal/host/ -run 'MemTier|ClaimBound'`. Expected: PASS.
+
+- [ ] **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
+
+**Files:**
+- Modify: `internal/activator/swap.go:493-508` (the captured tiers)
+- Test: `internal/activator/swap_overlap_test.go:71-100` (`TestSwapOverlapUsesEachArtifactsTier`)
+
+**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.
+
+- [ ] **Step 1: Change the existing test and add one**
+
+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`).
+
+```go
+func TestSwapSuccessorStartsAtTheLargerOfEffectiveAndIncumbent(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.
+}
+```
+
+- [ ] **Step 2: Run to see them fail**: `go test ./internal/activator/ -run 'TestSwapOverlapUsesEachArtifactsTier|TestSwapSuccessorStartsAtTheLarger'`. Expected: FAIL.
+
+- [ ] **Step 3: Implement** the one-line change at `swap.go:498`, with a comment citing spec §5.4 and R1-3.
+
+- [ ] **Step 4: Run** `go test ./internal/activator/ -run 'Swap'`. Expected: PASS.
+
+- [ ] **Step 5: Commit** (`activator: a swap's successor starts at the larger of its size and the incumbent's`)
+
+### Task 5: Root's `/size` verbs
+
+**Files:**
+- Create: `internal/instancectl/size.go` (`handleSize`, `handleSizeReadBack`, `parseSizeShow`)
+- Modify: `internal/instancectl/server.go:323-331` (`Handler`: `POST /size`, `GET /size`, both through `claimAwareLegacy`), `server.go:443-454` (`sliceTier` becomes `sliceSize` plus a wrapper), `server.go:~73-76` (new field `ControlDropInDir`, default `/run/systemd/system.control` at ~193)
+- Modify: `internal/instancectl/gen.go:83-101` (`removeGenFiles`), `gen.go:~145` (`startGen`, before `writeGenDropIn`)
+- Modify: `internal/instancectl/client.go` (two methods beside `Parent`, ~317)
+- Test: `internal/instancectl/size_test.go` (create), `internal/instancectl/server_gen_test.go`, `internal/instancectl/client_test.go`
+
+**Interfaces:**
+- Consumes: `cgroup.SizeByName`, `cgroup.SizeOf`, `cgroup.HighOf`.
+- Produces:
+ - `type SizeReadBack struct { SliceMemoryMax int64 \`json:"slice_memory_max"\`; CPUQuotaPercent int \`json:"cpu_quota_percent"\`; CPUWeight int \`json:"cpu_weight"\`; IOWeight int \`json:"io_weight"\`; UnitMemoryMax int64 \`json:"unit_memory_max,omitempty"\` }`
+ - `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) 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.
+
+`handleSize`, in order:
+1. Validate: host shape; `size` names a row; `memory_max` is some row's `MemoryMax`; `gen`, if present, is a positive canonical integer and `showValue(ActiveState, GenUnitName(host, gen)) == "active"`. Any failure: 400 (shape) or 409 (inactive generation), no write.
+2. If `slicePopulated(host)`: read `MemoryMax` of the slice, and of the generation unit when `gen` is set; refuse 409 `memory never shrinks while the slice is populated` if `memory_max` is below either. Memory never shrinks while anything runs (R1-3); the activator never asks, and root refuses anyway.
+3. `sliceSize(host, Limit{memory_max}, size.Shares)`. Failure: 502.
+4. With `gen`: `set-property --runtime <gen unit> MemoryMax=<n> MemoryHigh=<HighOf> MemorySwapMax=<n>`. Failure: 502 (the slice may be written: the activator marks the host unsettled).
+5. Read back with `systemctl show -p MemoryMax -p CPUQuotaPerSecUSec -p CPUWeight -p IOWeight <slice>` and, with `gen`, `show -p MemoryMax <unit>`; reply 200.
+
+`parseSizeShow` maps `CPUQuotaPerSecUSec` `500ms`/`1s`/`2s`/`4s` to 50/100/200/400 and anything else (including `infinity`) to 0; `MemoryMax=infinity` is 0.
+
+`startGen` removes `<ControlDropInDir>/<unit>.d` before `writeGenDropIn`, and `removeGenFiles` removes it with the other files; an empty `ControlDropInDir` skips both (tests). Systemd keeps a `set-property --runtime` on a unit until reboot, and it outranks the generation's own drop-in, so a reused generation number would otherwise start at an old size.
+
+- [ ] **Step 1: Write the failing tests** (`fakeCtl` from `server_test.go:20`, `newServer` from `:96`)
+
+```go
+func TestSizeGrowsTheSliceThenTheGenerationThenReadsBack(t *testing.T)
+// fakeCtl answers ActiveState=active for the gen unit, populated cgroup.events,
+// slice MemoryMax=536870912 and unit MemoryMax=536870912 for the pre-check, and show output for the read-back.
+// POST /size?host=shop-a.example.com&size=large&memory_max=1073741824&gen=3
+// systemctl calls, in order, include exactly:
+// "set-property --runtime carlos-instances-shop\\x2da.example.com.slice MemoryMax=1073741824 MemoryHigh=966367638 MemorySwapMax=1073741824 CPUWeight=100 CPUQuota=200% IOWeight=100"
+// "set-property --runtime <GenUnitName(host,3)> MemoryMax=1073741824 MemoryHigh=966367638 MemorySwapMax=1073741824"
+// reply decodes to the read-back the fake returned.
+func TestSizeShrinksCPUWithMemoryHeld(t *testing.T)
+// populated, slice at 1 GiB: size=small&memory_max=1073741824 writes
+// "... MemoryMax=1073741824 MemoryHigh=966367638 MemorySwapMax=1073741824 CPUWeight=50 CPUQuota=50% IOWeight=50"
+func TestSizeRefusesAMemoryShrinkWhilePopulated(t *testing.T) // slice at 1 GiB, memory_max=536870912 -> 409, no set-property
+func TestSizeRefusesAnInactiveGeneration(t *testing.T) // ActiveState=inactive -> 409, no set-property
+func TestSizeRefusesBadInputs(t *testing.T) // size=huge, memory_max=805306368, gen=0, gen=03, bad host -> 400, no systemctl
+func TestSizeUnitWriteFailureIs502(t *testing.T) // slice write ok, unit write fails -> 502
+func TestSizeReadBackParsesSystemd(t *testing.T) // parseSizeShow table: 500ms/1s/2s/4s/infinity, MemoryMax=infinity
+func TestStartGenClearsAStaleControlDropIn(t *testing.T) // a file under <ControlDropInDir>/<unit>.d is gone before the drop-in is written; removeGenFiles removes it too
+func TestClientSizeMapsRefusalsToErrSizeRefused(t *testing.T) // 400, 409 and 503 (claims on) -> errors.Is(err, ErrSizeRefused); 502 -> not
+```
+
+- [ ] **Step 2: Run to see them fail**: `TMPDIR=/tmp/g go test ./internal/instancectl/ -run 'TestSize|TestStartGenClears|TestClientSize'`. Expected: FAIL.
+
+- [ ] **Step 3: Implement** as described above. Log one line per applied change: `instance-control: %s: size %s memory.max=%d (gen %d)`.
+
+- [ ] **Step 4: Run** the step 2 command, plus `-run 'TestStart|Gen|Parent'` for the existing golden lines. Expected: PASS, with the existing `sliceTier` lines unchanged.
+
+- [ ] **Step 5: Commit** (`instancectl: POST and GET /size, slice then serving generation, read back`)
+
+### Task 6: The activator applies live changes
+
+**Files:**
+- Modify: `internal/activator/opset.go:72-87` (`opSet`), `opset.go:~108` (`joinOps` also waits on `resizeDone`)
+- Modify: `internal/activator/activator.go:469` (`actEntry`: `sizeSeen string`, `sizeSeenSet bool`, `sizeDirty bool`), `activator.go:4189-4206` (`finishPendingStops` calls `retrySizeLocked(e)` after `retryParentRestoreLocked(e)`)
+- Modify: `internal/activator/swap.go:467-471` (admission refuses while a change is in flight or unsettled)
+- Modify: `internal/activator/size.go` (Task 3's file), `internal/activator/containment.go` (ExecBackend's `SizeBackend` methods, beside `ParentLimit` ~365), `internal/activator/genbackend.go` (`SplitBackend` delegation, beside ~369)
+- Test: `internal/activator/size_live_test.go` (create), `internal/activator/swap_fixture_test.go` (the fake gains `SizeBackend`)
+
+**Interfaces:**
+- Consumes: `Client.Size`, `Client.ReadSize`, `ErrSizeRefused`, `SizeReadBack` (Task 5); `EffectiveSize`, `SizedLimit` (Task 3); `cgroup.MaxLimit` (Task 1).
+- Produces:
+ - `opSet` gains `resizing bool`, `resizeDone chan struct{}`, `sizeUnsettled bool`.
+ - `type SizeBackend interface { ResizeLive(ctx context.Context, rt registry.Route, gen uint64, size cgroup.Size, memoryMax int64) (instancectl.SizeReadBack, error); ReadSize(ctx context.Context, rt registry.Route, gen uint64) (instancectl.SizeReadBack, error) }`. `ExecBackend` implements it for `LayoutNested` only, through `genControl()`; any other layout returns `errSizeNotLive`.
+ - `func (a *Service) NoteSize(host string)`: sets `sizeDirty` on the entry, if any. It never blocks.
+ - `type SizeState struct { Gen uint64; Raised bool; Unsettled bool; Want cgroup.Size }`; `func (a *Service) SizeState(host string) (SizeState, bool)`: false unless awake and not a worker.
+ - Swap admission: `if len(e.ops.members) > 0 || e.ops.raised || e.ops.resizing || e.ops.sizeUnsettled { return errSwapBusy }`.
+
+`retrySizeLocked(e)` (caller holds `a.mu`), patterned on `retryParentRestoreLocked`:
+
+```
+skip unless: a.entries[e.host]==e, e.state==actAwake, !e.worker, e.route.Kind=="instance",
+ no members, !raised, !restoring, !resizing, backend is a SizeBackend
+if !e.sizeSeenSet { e.sizeSeen, e.sizeSeenSet = e.route.Size, true } // the wake's fresh row
+mark resizing + resizeDone; snapshot rt := e.route, g := e.gen, unsettled, dirty
+go (off a.mu):
+ fresh := a.Routes.Get(rt.Host) (nil Routes: rt); a read error ends the pass (retried next sweep)
+ if unsettled: rb, err := ReadSize(rt, g.N); on success set captured := rb.UnitMemoryMax (or the slice's when g.N==0) and clear unsettled
+ if !unsettled-now && !dirty && fresh.Size == e.sizeSeen: done // no operator change: no call, ever
+ want := EffectiveSize(fresh, g.Build)
+ held := g.Tier (after any settle), or the read-back slice memory when g.N==0
+ mem := MaxLimit(want.Limit, held) // memory never lowers (R1-3)
+ rb, err := ResizeLive(fresh, g.N, want, mem.MemoryMax)
+under a.mu:
+ err == nil: if e.gen.N == g.N && g.N > 0 { e.gen.Tier = Limit{rb.UnitMemoryMax} } // R2-1: from read-back, never from the request
+ e.sizeSeen, e.route.Size = fresh.Size, fresh.Size; clear dirty
+ errors.Is(err, ErrSizeRefused) or errSizeNotLive: clear dirty and record sizeSeen (nothing written; the change waits for the next start)
+ any other error: e.ops.sizeUnsettled = true (slice may be written; no swap starts until a read-back settles it)
+ 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.
+
+- [ ] **Step 1: Write the failing tests** (swap fixture; the fake records `size:<name>/<mem>/<gen>` and `readsize:<gen>` events and returns scripted read-backs)
+
+```go
+func TestLiveGrowthWritesSliceAndUnitAndCapturesTheReadBack(t *testing.T)
+// awake at gen 1, Tier 512 MiB; route row Size "" -> "large"; NoteSize; sweep:
+// one event "size:large/1073741824/1"; fake read-back UnitMemoryMax 1073741824;
+// afterwards Current(host).Tier == TierChildren.
+func TestLiveChangeWaitsForTheSwapToRestore(t *testing.T)
+// swap held mid-overlap (genGate); NoteSize; sweep: no size event; release the swap;
+// next sweep: exactly one size event, after the restore event.
+func TestSwapRefusedWhileResizingOrUnsettled(t *testing.T)
+// fake ResizeLive returns a 502-style error -> SizeState.Unsettled; Swap -> errSwapBusy (abandoned);
+// fake ReadSize succeeds on the next sweep -> Unsettled false, Gen.Tier from the read-back; Swap proceeds.
+func TestMemoryShrinkSendsOnlyCPUAndIOAndStaysPending(t *testing.T)
+// captured 1 GiB, Size "large" -> "small": event "size:small/1073741824/1" (memory held);
+// SizeState.Want is small; Current.Tier stays 1 GiB.
+func TestSizeBackToTheOneInForceClearsPending(t *testing.T)
+// from the previous state, Size -> "large": event "size:large/1073741824/1"; Want large.
+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 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 3: Implement** as above. `ExecBackend.ResizeLive`/`ReadSize` mirror `ParentLimit`'s layout switch (`containment.go:365-386`).
+
+- [ ] **Step 4: Run** the step 2 command and `go test ./internal/activator/ -run 'Swap|Restore|Join'`. Expected: PASS.
+
+- [ ] **Step 5: Commit** (`activator: live size changes under the operation set; captured tier from read-back; unsettled hosts start no swap`)
+
+### 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` (~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)
+- Modify: `internal/status/snapshot.go:230-237` (`RowLayout.Size string \`json:"size,omitempty"\``), BoxMetrics (~469-600: `SizeCommittedMiB *int64 \`json:"size_committed_mib,omitempty"\``)
+- 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`.
+- 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.
+ - `func sizeCell(inForce cgroup.Size, ok bool, st activator.SizeState) string`: `swap` while `st.Raised`; `?` when `!ok`; `inForce.Name` when it equals `st.Want.Name`; else `inForce.Name + ">" + st.Want.Name`. Rows not awake, flat rows and workers: `-`.
+ - `func sizeCommittedMiB(src *sizeSource, routes []registry.Route) *int64`: the sum of `EffectiveSize(rt, current build).Limit.MemoryMax >> 20` over awake instance entries; nil with no source armed.
+
+The read is of cgroupfs, which is what systemd wrote and the kernel holds: a read-back, not intent. It is the agent's own read (no root call per status tick).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestSizeCell(t *testing.T) // table: raised -> "swap"; !ok -> "?"; large/large -> "large"; large in force, want small -> "large>small"; standard in force, want xlarge -> "standard>xlarge"
+func TestReadSizeInForcePrefersTheServingUnit(t *testing.T)
+// fixture tree under t.TempDir(): <slice>/memory.max=2147483648 (raised), <slice>/<gen unit>/memory.max=1073741824 -> large;
+// no gen dir -> the slice's; "max" -> false
+func TestLayoutReportCarriesSize(t *testing.T) // an armed source whose SizeState says awake gen 2 -> RowLayout.Size from the fixture; an asleep row -> "-"
+func TestSizeCommittedSumsAwakeEffectiveSizes(t *testing.T) // two awake (small, xlarge) and one asleep (large) -> 256+2048 = 2304
+```
+
+- [ ] **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 4: Run** the step 2 command. Expected: PASS. Cross-compile check: `GOOS=darwin go build ./internal/host/ && GOOS=windows go build ./internal/host/`.
+
+- [ ] **Step 5: Commit** (`host: size changes nudge the activator; status carries the size in force and the committed figure`)
+
+### Task 8: `mem_total_mib` in the edges file
+
+**Files:**
+- Modify: `internal/host/edgesfile.go:229-330` (`edgesFile.MemTotalMiB int64 \`json:"mem_total_mib,omitempty"\``, top level, beside plan 2's `addrs`), `edgesfile.go:~826` (`edgesFileLoop`'s `ident`: set once from `capacityMiB(readMemBytes, ...)`'s memory half)
+- Modify: `internal/edgesfile/snapshot.go:28-99` (`PlacementView.MemTotalMiB int64 \`json:"mem_total_mib"\``)
+- Test: `internal/host/edgesfile_test.go`, `internal/edgesfile/snapshot_test.go`
+
+**Interfaces:**
+- Produces: `PlacementView.MemTotalMiB` (0 = missing or unreadable), read by Task 9.
+
+The field is part of the write gate (`edgesfile.go:659-668`) because only `Headroom` is cleared from the gate copy, so a changed total rewrites the file. It does not depend on region HA or on headroom freshness (Astra R2-2).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestEdgesFilePublishesMemTotal(t *testing.T) // ident.MemTotalMiB = 3884 -> the written JSON has "mem_total_mib":3884 at top level, with region HA off (no headroom)
+func TestEdgesFileMemTotalIsInTheGate(t *testing.T) // same file, total changes 3884 -> 7820 -> a second write; unchanged -> none
+func TestPlacementViewReadsMemTotal(t *testing.T) // parse; absent -> 0
+```
+
+- [ ] **Step 2: Run to see them fail**: `go test ./internal/host/ -run 'EdgesFile.*MemTotal' && go test ./internal/edgesfile/ -run PlacementViewReadsMemTotal`. Expected: FAIL.
+
+- [ ] **Step 3: Implement.**
+
+- [ ] **Step 4: Run** the step 2 command and `go test ./internal/host/ -run EdgesFile`. Expected: PASS.
+
+- [ ] **Step 5: Commit** (`host: publish mem_total_mib in the edges file`)
+
+### Task 9: The console's quarter-of-a-box refusal and committed report
+
+**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
+- Test: `internal/console/policysize_test.go` (create)
+
+**Interfaces:**
+- Consumes: `PlacementView.MemTotalMiB` (Task 8), `BoxMetrics.SizeCommittedMiB` (Task 7), `readEdgesSnapshot` (`instancesplacement.go:82`), `loadEdgesJoin` (`:48`), `s.homeRegionIn` (`apiinstancemove.go:306`), `s.systemBoxes`.
+- Produces:
+ - `func (s *Server) sizeBoxes(snap edgesSnapshot, recs []provision.Record) []edgesFilePlacementView`: a box is in the set when it serves one of the records' hosts, or when `s.homeRegionIn(f.Placement) == s.homeRegionIn(rec.Region)` for one of them. With no records (an app default on an app with no instances yet), every box in the snapshot.
+ - `func (s *Server) sizeCapacityCheck(ctx context.Context, recs []provision.Record, size cgroup.Size) error`: refuses (409, the door's refusal shape) when `size.Limit.MemoryMax >> 20 > f.MemTotalMiB / 4` for the smallest-total box, naming it and its total; when any box in the set has `MemTotalMiB == 0` and the size is above standard, naming that box; when the edges listing failed or `!snap.Complete` and the size is above standard. `small` and `standard` are never refused for a missing total.
+ - `type sizeCommittedBox struct { Box string \`json:"box"\`; CommittedMiB int64 \`json:"committed_mib"\`; TotalMiB int64 \`json:"total_mib"\`; FreeMiB int64 \`json:"free_mib"\` }`; `func (s *Server) sizeCommitted(ctx context.Context, boxes []string) []sizeCommittedBox` from each box's status snapshot (`SizeCommittedMiB`, `MemTotalBytes`, `MemAvailBytes`); a box without them is left out. Information, never a gate.
+
+Placement (`boxchoice`) is unchanged.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestSizeCapacityRefusesAboveAQuarterOfTheSmallestBox(t *testing.T)
+// boxes 3884 and 7820 MiB serving the app: xlarge (2048 > 971) refused naming the 3884 box and "3884";
+// large (1024 > 971) refused; standard passes.
+func TestSizeCapacityRefusesAboveStandardWhenATotalIsMissing(t *testing.T) // one box with no mem_total_mib: large refused naming it; standard and small pass
+func TestSizeCapacityRefusesAboveStandardOnAnIncompleteSnapshot(t *testing.T) // an unreadable edges file: large refused; standard passes
+func TestSizeBoxesUsesPlacementAndServingBoxes(t *testing.T) // a box in another region that serves nothing is not in the set; one serving a host is
+func TestSizeCommittedReportsEachBox(t *testing.T) // status snapshot with size_committed_mib 2304, total 3884 MiB, avail 1200 MiB -> {box, 2304, 3884, 1200}
+func TestPolicyDoorRefusesAnOversizeBeforeTheAuditWrite(t *testing.T) // through plan 1's door: refused change writes no audit event and no policy object
+```
+
+- [ ] **Step 2: Run to see them fail**: `go test ./internal/console/ -run 'SizeCapacity|SizeBoxes|SizeCommitted|PolicyDoorRefusesAnOversize'`. Expected: FAIL.
+
+- [ ] **Step 3: Implement.** Refusal texts are Task 14's strings 1-3.
+
+- [ ] **Step 4: Run** the step 2 command. Expected: PASS.
+
+- [ ] **Step 5: Commit** (`console: refuse a size above a quarter of the smallest box; report each box's committed figure`)
+
+### Task 10: SIZE on screen
+
+**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`
+- Test: `status_instances_test.go` (or the file holding `printStatusLayout` tests), `internal/console/apistatuslayout_test.go`
+
+**Interfaces:**
+- Consumes: `RowLayout.Size` (Task 7), `sizeCommittedBox` (Task 9).
+- Produces: `func printSizeCommitted(w io.Writer, boxes []sizeCommittedBox)` (CLI side decodes its own copy of the struct).
+
+Heading with this plan alone: `BOX HOST LAYOUT SIZE REASON`. With plan 2's `EGRESS` merged: `BOX HOST LAYOUT SIZE EGRESS REASON`. An empty `Size` from an older box prints `-`. `instances list` does not gain SIZE (its table is already thirteen columns; spec §10 leaves this to the builder).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestStatusLayoutShowsSize(t *testing.T)
+// rows with Size "large", "large>small", "swap", "" -> cells large, large>small, swap, -;
+// heading exactly "BOX HOST LAYOUT SIZE REASON"; the pending footnote appears once, only when a ">" cell exists
+func TestStatusLayoutDoorCarriesSize(t *testing.T) // a box snapshot row with size "xlarge" reaches the door's JSON
+func TestPrintSizeCommitted(t *testing.T) // one line per box, Task 14's string 5 with 2304, 3884, 1200
+```
+
+- [ ] **Step 2: Run to see them fail**: `go test . -run 'StatusLayoutShowsSize|PrintSizeCommitted' && go test ./internal/console/ -run StatusLayoutDoorCarriesSize`. Expected: FAIL.
+
+- [ ] **Step 3: Implement.** Strings are Task 14's 4-6, verbatim once approved.
+
+- [ ] **Step 4: Run** the step 2 command. Expected: PASS.
+
+- [ ] **Step 5: Commit** (`cli: SIZE in status --layout; committed figures after a size change`)
+
+### Task 11: Production-wired witnesses on the root runner
+
+**Files:**
+- Create: `internal/instancectl/size_rootrunner_linux_test.go` (`//go:build linux && rootrunner`)
+- Modify: `internal/host/plan14_installed_launch_root_linux_test.go` (a size parameter to `plan14InstalledClaimLaunch`, ~149) and `internal/host/claimlaunch_transport_test.go:120` (`TestClaimRoleLaunchEndToEndThroughTheRootProtocol` over small and xlarge)
+
+**Interfaces:**
+- Consumes: everything above, through the real control service and units.
+
+- [ ] **Step 1: Write the witnesses**
+
+```go
+func TestSizesThroughEveryStartPathRootRunner(t *testing.T)
+// for small and xlarge, through the real Server and systemd: run, generation, seed, sidecar, cmd.
+// After each start: `systemctl show -p MemoryMax -p MemoryHigh -p CPUQuotaPerSecUSec -p CPUWeight -p IOWeight <slice>`
+// equals the table row (small: 268435456, 241591905, 500ms, 50, 50; xlarge: 2147483648, 1932735276, 4s, 200, 200).
+func TestLiveGrowthReachesTheServingGenerationsOwnCapRootRunner(t *testing.T)
+// a generation started standard; POST /size large with gen: the unit's MemoryMax is 1073741824 and its
+// cgroup memory.max reads 1073741824; a process allocating 700 MiB in it survives.
+func TestMemoryShrinkLeavesARunningProcessAloneRootRunner(t *testing.T)
+// generation at large holding 700 MiB; POST /size small with memory_max=1073741824: the process lives;
+// a POST with memory_max=268435456 is 409; stop, cold start at small: slice MemoryMax 268435456.
+func TestReusedGenerationNumberStartsAtItsOwnSizeRootRunner(t *testing.T)
+// gen 1 grown to xlarge live, stopped; gen 1 started again at standard: the unit's MemoryMax is 536870912.
+```
+In `plan14InstalledClaimLaunch`, run the installed launch once with the route's `Size` set to `small` and once to `xlarge`, and assert the slice's `MemoryMax` read back from systemd. In the transport end-to-end test, set `MemoryMax` from the two sizes and assert root accepted and the reserved role carries it.
+
+- [ ] **Step 2: Run** with the sandbox off:
+`~/.cache/tmp/claude-1001/own/after236/root-runner.py --worktree $PWD --pkg ./internal/instancectl --run 'RootRunner$'` and `--pkg ./internal/host --run 'TestPlan14InstalledClaimLaunchRootRunner'`; locally `go test ./internal/host/ -run TestClaimRoleLaunchEndToEndThroughTheRootProtocol`.
+Expected: PASS. A failure here is a defect in Tasks 2-6, fixed there.
+
+- [ ] **Step 3: Commit** (`sizes: root-runner witnesses for every start path, live growth, shrink and the claim launch`)
+
+### Task 12: The byte-identical proof
+
+**Files:**
+- Create: `internal/instancectl/size_identical_test.go`, `internal/activator/size_identical_test.go`
+
+- [ ] **Step 1: Write the tests** (they should pass at once; they pin the claim)
+
+```go
+func TestNoSizeColumnIsByteIdenticalToMain(t *testing.T)
+// instancectl: for TierDefault and TierChildren, sliceTier's set-property line, GenDropIn(host, 3, tier)
+// and /parent's line equal literal strings copied from main at the plan's base commit
+// (e.g. "MemoryMax=536870912 MemoryHigh=483183819 MemorySwapMax=536870912 CPUWeight=100 CPUQuota=100% IOWeight=100").
+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.
+```
+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).
+
+- [ ] **Step 2: Run** `TMPDIR=/tmp/g go test ./internal/instancectl/ -run NoSizeColumn && go test ./internal/activator/ -run NoSizeColumn`. Expected: PASS.
+
+- [ ] **Step 3: Commit** (`sizes: prove an empty size column is byte-identical to main`)
+
+### Task 13: Census overlay
+
+**Files:**
+- Create: `internal/provision/testdata/plan14-root-reads-instance-sizes.tsv`
+- Create: `docs/superpowers/notes/census-proposal-instance-sizes.md`
+- Modify: `internal/provision/plan14_root_reads_test.go:~1100` (load the overlay after plan 2's, which loads after `ci-speed-activator`; check the previous seal, then pin the new one)
+
+- [ ] **Step 1: Write the proposal and overlay**, in the shape of `census-proposal-moves-offline-fixes.md`. Rows: `instancectl` `handleSize` and `handleSizeReadBack` (new: root, `systemctl show` reads of a slice and a generation unit, a `cgroup.events` read); `parseRequest`, `claimTier`, `sliceTier`/`sliceSize`, `startGen`, `removeGenFiles` (changed: the control drop-in removal is a root file removal under `/run/systemd/system.control`, keyed by a validated unit name); `host` `claimLaunchTier`, `claimIsolatedPaths`, `claimBoundSnapshot` (changed legacy, no new read); `cgroup` `prepareStrict`, `PrepareGen`, `ValidParentLimit`, `SharesOf` (changed legacy, no read); `host` `readSizeInForce` (new: the agent's cgroupfs read, J4). Every row carries `rev3=PROPOSED-instance-sizes`.
+
+- [ ] **Step 2: Run** `go test ./internal/provision/ -run TestPlan14RootReadCensus`. Expected: PASS with the new seal.
+
+- [ ] **Step 3: Commit** (`census: overlay instance-sizes, proposed`). The #280 ruling is asked before merge.
+
+### Task 14: Copy review
+
+Collect every user-facing string from Tasks 9 and 10 and run the `copy-review` skill before merge. Apply the approved text verbatim, and re-run the tests that pin it.
+
+| # | Where | Draft |
+|---|---|---|
+| 1 | door refusal, `sizeCapacityCheck` | `%s is more than a quarter of the memory on %s (%d MiB). Choose a smaller size.` |
+| 2 | door refusal, missing total | `%s has not published its memory total, so only small and standard can be set. Upgrade the box, then try again.` |
+| 3 | door refusal, incomplete read | `Could not read every box's memory total, so only small and standard can be set. Try again in a minute.` |
+| 4 | `status --layout` heading | `BOX HOST LAYOUT SIZE REASON` (with plan 2: `... SIZE EGRESS REASON`) |
+| 5 | CLI after a size change, per box | `%s: %d MiB committed to awake instances, of %d MiB. %d MiB free.` |
+| 6 | `status --layout` footnote | `A size after > takes effect when the instance next starts cold. Restart the instance to apply it now.` |
+
+The cell words `swap`, `-` and `?` are reviewed with string 4. Root's `/size` error texts and the activator's log lines are internal and not reviewed.
+
+- [ ] **Step 1:** Invoke `copy-review` with the table; wait for the result.
+- [ ] **Step 2:** Apply it; run `go test . -run 'StatusLayoutShowsSize|PrintSizeCommitted' && go test ./internal/console/ -run 'SizeCapacity'`. Expected: PASS.
+- [ ] **Step 3: Commit** (`sizes: approved copy`)
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 17f48dda1..e445e649b 100644
--- a/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md
+++ b/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md
@@ -1,8 +1,9 @@
# Instance sizes and egress allows
Amadan #328 (an isolated instance reaching a private service its operator
-runs) and #329 (operator-set CPU and memory per instance). Revision 7,
-2026-10-21. Revisions 2 to 7 answer the builder check, Astra rounds
+runs) and #329 (operator-set CPU and memory per instance). Revision 8,
+2026-10-21. Revision 8 folds in what the three plan writers found
+against the code (§9). Revisions 2 to 7 answer the builder check, Astra rounds
1 to 6, and Paul's ruling on fleet addresses (`CODEX-FINDINGS-instance-sizes-egress-R<n>.md`); the
dispositions are §9. **Approved by Paul, 2026-10-21.**
@@ -131,6 +132,28 @@ that way.
older console that never touches this object) does not inherit the old
instance's size (Astra R2-3). Deleting an instance through a new
console also removes the entry, for tidiness.
+- **The policy is a signed record** (rev 8; plan 1 found it). Under
+ claims the agent is treated as compromised, but it holds the box's
+ cloud role and can write `control/app-policy/`, so an unsigned object
+ read from the store would let it forge an allow. The record follows the
+ door-key precedent (`internal/doorkey`, signed in
+ `console/apidoorkeys.go:142`, verified offline in
+ `edge/doorkeys.go:154`): the console signs it with the release key under
+ its own domain separator (`carlos app-policy v1\n`), with a monotonic
+ `revision` and a 72-hour lifetime, and re-signs it well inside that
+ lifetime on the same loop that re-signs door keys. Every reader verifies
+ it: the agent before converging (§4.3 pre-claim), root itself under
+ claims. Root keeps a per-app revision floor in its own state and refuses
+ a lower revision (an old signed record replayed by the agent), reporting
+ `policy replayed`. An expired or unverifiable record is treated as no
+ policy for allows (none written, reported `policy expired` or `policy
+ unverifiable`) and as no override for sizes (the build's tier, from the
+ next start; never a live shrink). A console down for longer than the
+ lifetime therefore closes granted addresses, the door keys' trade.
+- **Under claims root reads one policy per app**, not per host, at most
+ 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.
- **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
@@ -259,7 +282,10 @@ may run (`validateStagedExe`):
column and validates it. This adds no trust the agent does not
already hold. **One extra rail:** a box whose layout is not wholly nested (any
flat exec child, which runs as the agent's user and may be able to
- write `registry.db`) gets no allow written at all, and says so.
+ write `registry.db`) gets no allow written at all, and says so. Sidecar
+ members (`Kind "sidecar"` rows, outside the layout pass,
+ `layoutleg.go:195`) run flat, so a box hosting any counts as not wholly
+ nested, and sidecar members themselves get no policy (rev 8).
**Transport.** A box with direct store access reads the policy object
itself. A bucketless fleet box reads through the console's store door
@@ -344,9 +370,9 @@ wins.)
effective = sizes[host], else default_size, else the build's tier
where the build's tier is today's rule (`children` → `large`, else
-`standard`). A size below the build's tier is allowed and the CLI warns
-("this build declares children; small may be OOM-killed"). The operator
-owns the call; the platform never silently raises it. (My call.)
+`standard`). A size below the build's tier is allowed. The operator owns the call;
+the platform never silently raises it. (My call. Rev 8 drops the CLI
+warning: the console cannot see a build's `children` bit today.)
`EffectiveSize(policy, host, buildTier)` sits beside `EffectiveUpdates`;
the agent converges it into a registry `size` column.
@@ -360,7 +386,9 @@ the agent converges it into a registry `size` column.
claim bound-unit path's hard-coded `TierDefault`
(`claimunit_inputs.go:74`) goes.
- **Every validator takes the shared table** (Astra R1-6):
- `parseRequest`, `claimLaunchTier` (`claimlaunch_root.go:163`),
+ `parseRequest`, `prepareStrict` (`internal/cgroup/strict_linux.go:216`)
+ and `PrepareGen` (`strict_gen_linux.go:162`) (rev 8),
+ `claimLaunchTier` (`claimlaunch_root.go:163`),
`claimIsolatedPaths` (`claimroleassembly.go:124`), `claimTier`
(`claimunit.go:67`) and `ValidParentLimit` (one size, or the sum of
any two: a swap overlap, including one across a resize). `SharesOf`
@@ -394,6 +422,10 @@ retire and restore the slice. The populated-slice skips in `startGen`
(`gen.go:116`) and the run path (`server.go:503`) stay exactly as they
are: they protect the overlap allowance (Astra R1-1).
+- **Under host claims there is no live change** (rev 8): the legacy
+ control verbs answer 503 under claims, so a new size, growth included,
+ takes effect at the host's next start. Claims are dormant; a live verb
+ for claim mode is follow-up work.
- **Growing** (memory up, or any CPU or IO change): applied live by a
`POST /size?host=` call. Root sets the slice first, then the serving
generation unit (`set-property --runtime` on the unit), and reads both
@@ -414,8 +446,12 @@ are: they protect the overlap allowance (Astra R1-1).
restore runs while the old sidecar and the retiring generation's `carlos
run` commands may still hold memory (`swap.go:678`,
`activator/cmd.go:336,377`). So:
- - a swap's successor starts at **the larger of** the effective size
- and the incumbent's size, so the restore never lowers the parent;
+ - 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
+ 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
operator asks for); CPU and IO shrinks still apply live, as they kill
@@ -501,6 +537,11 @@ default and the overrides.
and writes none on a box that is not wholly nested; with claims, root
reads the store itself (§4.3).
- Sizes stay agent-supplied and root-checked against the set (§5.3).
+- Rev 8: the policy is signed and expires 72 hours after signing, so a
+ console down that long closes granted addresses (§3).
+- Rev 8: a box running any flat sidecar member writes no allows before
+ claims (§4.3).
+- Rev 8: under claims a new size waits for the next start (§5.4).
## 9. Review dispositions
@@ -562,6 +603,19 @@ does exist, at `internal/activator/`.
|---|---|---|
| R5-1 | An older console in a mixed rollout can purge and recreate under a live policy | Accepted: rollout rule (every console upgraded before the first grant), and an `app_id` binding root checks under claims (§3) |
+**Plan writers (rev 8):** the policy object is agent-writable under
+claims, so it is signed (§3); root reads per app on a 30-second
+conditional read (§3); two more validators (`prepareStrict`,
+`PrepareGen`, §5.3); the swap max rule is limited to operator-set sizes
+to keep no-policy hosts byte-identical (§5.4); no live size change under
+claims (§5.4); sidecar members make a box not wholly nested and get no
+policy (§4.3); the below-tier CLI warning is dropped (§5.2). Also from
+the plans, recorded here so nobody re-derives them: a long host's slice
+name is a hash, so root closes the allow on a slice it cannot map to a
+host; a generation's live `set-property` lands in `system.control` and
+outranks its drop-in, so root removes it when a generation starts or
+stops.
+
## 10. Left to the builder
- Column names, the call shapes, and the status snapshot fields.