carlos / platform Public

plans 1-3: Astra plan review R1 (11 findings applied)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 676b844c7cd1935fc5274e46a86feb31dcbe6512 parent 733c554
5 files changed, +278 −106
  • docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md +146 −65
  • docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md +52 −17
  • docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md +55 −23
  • docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md +2 −1
  • docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R1.md +23 −0
diff --git a/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md b/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
index eb36502f8..e69b5b20d 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
@@ -2,9 +2,9 @@
> **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`), signed with the release key, with its validation, its door, CLI, audit, re-sign loop, purge and create-time clear, its read path for fleet-door boxes, and the agent verifying it and converging it into two registry columns, so plans 2 (egress) and 3 (sizes) only have to read it.
+**Goal:** The operator-only per-app policy object (`control/app-policy/<sqid>/<app>.json`), signed with the release key, with its validation, its door, CLI, audit, re-sign loop, purge and create-time clear, its read path for fleet-door boxes, and the agent verifying it and converging it into registry columns (the allow, the size and the record's expiry), 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 object is a signed, revisioned, expiring record on the `internal/doorkey` pattern (spec §3, rev 8): the console signs it on every door write with a revision one above the current, and re-signs every live policy on the door keys' 12-hour loop. The console writes it through one CAS door gated by `apiSystemRoller`, auditing first. `provision.Reconcile` reads it once per pass (one LIST, then GETs), verifies it, and converges `routes.egress_allow` / `routes.size` on every instance row of the app. Nothing on the box enforces anything in this plan.
+**Architecture:** Two small leaf packages (`internal/egressaddr`, `internal/apppolicy`) plus size names in `internal/cgroup` hold every rule. The object is a signed, revisioned, expiring record on the `internal/doorkey` pattern (spec §3, rev 8): the console signs it on every door write with a revision one above the current, and re-signs every live policy on the door keys' 12-hour loop. The console writes it through one CAS door gated by `apiSystemRoller`, auditing first. `provision.Reconcile` reads it once per pass (one LIST, then GETs), verifies it, and converges `routes.egress_allow` / `routes.size` / `routes.policy_expires_at` on every instance row of the app. Nothing on the box enforces anything in this plan.
**Tech Stack:** Go, SQLite (modernc), `net/netip`, `crypto/ed25519`, the existing `release.Store` contract.
@@ -29,7 +29,7 @@
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`.
+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) until the recorded expiry passes, then cleared; 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 11 `TestAppsSetPolicyRefusesClientSide`.
## Boundary verification (spec §3 precondition), recorded
@@ -48,15 +48,16 @@ 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`.
-- The signed record (Task 3; the brief pins `Sign`, `Verify`, `VerifyRevision`, `Lifetime`, `Revision`, `SignedAt`): `apppolicy.ErrInvalid`, `ErrUnverifiable`, `ErrExpired`; `MaxClockSkew = 5 * time.Minute`; `func VerifyKey(pub ed25519.PublicKey, wire []byte, now time.Time, sqid, app string) (Policy, error)` (`Verify` plus "the record names this key's account and app"; root uses it on bytes it read itself); `func (p Policy) Expired(now time.Time) bool`; `func GetForUpdate(ctx, s, pub, sqid, app) (*Policy, string, error)` (console only: signed records whether expired or not); `apppolicy.PutIfMatchETag(...) (string, error)`; `apppolicy.Present(ctx, s) (map[string]bool, error)`; `(*Policy).SizeFor(host string, createdAt time.Time) string`; `(*Policy).EgressColumn() string`.
+- The signed record (Task 3; the brief pins `Sign`, `Verify`, `VerifyRevision`, `Lifetime`, `Revision`, `SignedAt`): `apppolicy.ErrInvalid`, `ErrUnverifiable`, `ErrExpired`; `MaxClockSkew = 5 * time.Minute`; `func VerifyKey(pub ed25519.PublicKey, wire []byte, now time.Time, sqid, app string) (Policy, error)` (`Verify` plus "the record names this key's account and app"; root uses it on bytes it read itself); `func (p Policy) Expired(now time.Time) bool`; `func GetForUpdate(ctx, s, pub, sqid, app) (*Policy, []byte, string, error)` (console only: signed records whether expired or not, with their bytes); `func OpenKey(pub ed25519.PublicKey, wire []byte, sqid, app string) (Policy, error)` (console only: `VerifyKey` without the expiry, for bytes the console recorded); `func PutWire(ctx context.Context, s release.Store, sqid, app string, wire []byte, etag string) (string, error)` (console only: writes bytes already signed and recorded); `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, old, next []string) (notChecked []string, status int, msg string)` in `internal/console/apipolicy.go`. `old` is the stored list, `next` the normalized new one. **Plan 1's body is a stub returning `[]string{}, 0, ""`. Plan 2 fills it** (the fleet-address check, §4.2, on `next` minus `old`). It is called whenever the request carries `egress_allow`; a non-zero `status` refuses with `msg` before the audit. The reply's `not_checked` is its first return.
- Plan 3 inserts its capacity check (§5.5) in `handleAPIPolicyPut` after size validation and before `checkEgressGrant`. There is no below-tier CLI warning (spec §5.2 rev 8 drops it; the console cannot see a build's `children` bit), and no plan implements one.
- `provision.Reconcile` writes `routes.size` = the configured size only (`''` = nothing configured = the build's tier). Resolving `''` against the build is plan 3's.
-- `provision.ReconcileConfig.PolicyChanged func(host string)`: called after each successful (not dry-run) `SetPolicy`, nil = no call. Plan 3 sets it to the activator's `NoteSize`.
+- `provision.ReconcileConfig.PolicyChanged func(host string)`: called after each successful (not dry-run) `SetPolicy` that changed `egress_allow` or `size` (not one that moved only the expiry), nil = no call. Plan 3 sets it to the activator's `NoteSize`.
+- `registry.Route.PolicyExpiresAt int64` (`routes.policy_expires_at`, unix seconds, `0` = none recorded) and `SetPolicy(host, egressAllow, size string, expiresAt int64)` (brief, Astra plan R1): the verified record's `SignedAt + Lifetime`, converged beside the two columns. Plan 2's pre-claim source treats an allow whose expiry is `0` or past as no allow, so a store outage longer than the lifetime closes it on the box (Astra plan R1-3).
- Policy unreadable, unverifiable or expired on the agent path: columns cleared, the log line `provision: <acct>/<app>: policy <word>: <reason>` with `<word>` one of `unreadable`, `unverifiable`, `expired`, and the word published by `func PolicyReadStates() map[string]string` (key `<sqid>/<app>`, value `policy unreadable` / `policy unverifiable` / `policy expired`; absent when the last pass read the app's policy cleanly). Plan 2 Task 11 carries it to the status snapshot.
-- Sidecar members (`Kind "sidecar"` rows, `ReconcileSidecars`) get no policy: both columns stay `''` (spec §4.3 rev 8). Plan 2 counts any such row as making the box not wholly nested.
+- Sidecar members (`Kind "sidecar"` rows, `ReconcileSidecars`) get no policy: the columns stay `''` and `0` (spec §4.3 rev 8). Plan 2 counts any such row as making the box not wholly nested.
---
@@ -179,11 +180,13 @@ git commit -m "egressaddr: the egress-allow address rules and the fence table (#
| 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}` |
+| `standard` | `512 << 20` | `{100, 100, 100}` |
+| `large` | `1 << 30` | `{100, 200, 100}` |
| `xlarge` | `2 << 30` | `{200, 400, 200}` |
-- [ ] **Step 1: Write the failing test**
+Every cell is a literal. The table never names `TierDefault`, `TierChildren`, `SharesOf` or `HighOf`: plan 3 Task 1 derives the tiers and `SharesOf` from this table, so a table built from them would then be a Go initialization cycle (Astra plan R1-10). The test, not the initializer, keeps `standard` and `large` equal to today's tiers.
+
+- [ ] **Step 1: Write the failing tests**
```go
func TestSizesTable(t *testing.T) {
@@ -194,10 +197,14 @@ func TestSizesTable(t *testing.T) {
// SizeOf(TierDefault).Name == "standard"; SizeOf(Limit{MemoryMax: 3 << 30}) not ok
// SizeNames() == []string{"small","standard","large","xlarge"}
}
+func TestSizesTableIsLiteral(t *testing.T) {
+ // go/parser over sizes.go: the initializer of `Sizes` contains no identifier
+ // TierDefault, TierChildren, SharesOf or HighOf (plan 3 derives those from it)
+}
```
-- [ ] **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 2: Run, fail:** `go test ./internal/cgroup -run 'TestSizesTable'` → FAIL (undefined).
+- [ ] **Step 3: Implement `sizes.go`** with the literal rows above. Comment: plan 3 makes this the source of every number (spec §5.1) and derives `TierDefault`, `TierChildren` and `SharesOf` from it, which is why the rows are literals; today only the console validates against it.
- [ ] **Step 4: Run, pass.**
- [ ] **Step 5: Commit** `cgroup: size names (#329)`.
@@ -222,8 +229,10 @@ func TestSizesTable(t *testing.T) {
- `func VerifyKey(pub ed25519.PublicKey, wire []byte, now time.Time, sqid, app string) (Policy, error)`
- `func (p Policy) Expired(now time.Time) bool`: `!now.Before(p.SignedAt.Add(Lifetime))`
- `func Get(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string, now time.Time) (*Policy, string, error)`
- - `func GetForUpdate(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string) (*Policy, string, error)`
+ - `func GetForUpdate(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string) (*Policy, []byte, string, error)`
+ - `func OpenKey(pub ed25519.PublicKey, wire []byte, sqid, app string) (Policy, error)`
- `func PutIfMatch(ctx context.Context, s release.Store, priv ed25519.PrivateKey, p Policy, etag string) error`; `func PutIfMatchETag(...same...) (string, error)`
+ - `func PutWire(ctx context.Context, s release.Store, sqid, app string, wire []byte, etag string) (string, error)`: `s.Put(ctx, Key(sqid, app), wire, etag)`, for bytes the console signed and recorded first (Task 8)
- `func Delete(ctx context.Context, s release.Store, sqid, app string) error`
- `var ErrInvalid = errors.New("apppolicy: invalid policy")`, `ErrUnverifiable = errors.New("apppolicy: policy unverifiable")`, `ErrExpired = errors.New("apppolicy: policy expired")`
- `release.ErrRefused = errors.New("store: refused")`
@@ -240,7 +249,8 @@ Rules (the `internal/doorkey` shape, `doorkey.go:155-240`, kept line for line wh
- **`VerifyRevision`:** `open` only, expired or not: the publisher's read, so the next write stays above it.
- **`VerifyKey`:** `Verify`, then `p.Account == sqid && p.App == app`, else `ErrUnverifiable` naming the account and app the record carries.
- **`Get`** (every box-side reader): absent → `nil, "", err` with `errors.Is(err, os.ErrNotExist)`; any store error passes through wrapped (a `release.ErrRefused` stays matchable); otherwise `VerifyKey(pub, body, now, sqid, app)`, and on its error `nil, etag, err` (`ErrUnverifiable` or `ErrExpired`). The ETag is returned so a caller can tell the object changed. Unknown JSON fields inside the payload are tolerated (a newer console may add some).
-- **`GetForUpdate`** (the console's door and re-sign loop only): as `Get` but with `open` plus the account and app check, no expiry, so an operator's policy outlives a console that was down past `Lifetime` and is re-signed when it returns (the door keys' trade, spec §3).
+- **`OpenKey`**: `open` plus the account and app check, no expiry; errors wrap `ErrUnverifiable`.
+- **`GetForUpdate`** (the console's door and re-sign loop only): as `Get` but with `OpenKey` (no expiry), returning the stored bytes beside the policy and the ETag (bytes and ETag also on `ErrUnverifiable`), so an operator's policy outlives a console that was down past `Lifetime` and is re-signed when it returns (the door keys' trade, spec §3).
- **`Validate`** (shape only, per brief): `V == 1`; `DefaultSize` is `""` or a known size; each `Sizes` key passes `controlname.ValidHostShape` and is lower case, its `Size` is a known size and its `CreatedAt` is non-zero; `len(EgressAllow) <= egressaddr.MaxAllow`; each entry `e` satisfies `Parse(e)` with `Parse(e).String() == e`, strictly ascending by `netip.Addr.Compare` (the stored form is canonical). Errors wrap `ErrInvalid`. It does not look at the signing fields.
- **`PutIfMatchETag`** signs first (refuses with `ErrInvalid`) and calls `s.Put(ctx, Key(p.Account, p.App), wire, etag)`; `etag == ""` is create-only (the Store contract). `release.ErrCASConflict` passes through. The caller chooses `Revision` and `SignedAt` (Task 8: current + 1, now; Task 9: unchanged, now).
- `SizeFor`: the override for `host` if its `CreatedAt.Equal(createdAt)`, else `DefaultSize`, else `""`. `EgressColumn`: `strings.Join(EgressAllow, " ")`.
@@ -274,6 +284,7 @@ func TestVerifyRevisionIgnoresExpiry(t *testing.T) {
}
func TestVerifyKeyBindsTheRecordToItsKey(t *testing.T) {
// p1 (ab12/shop): VerifyKey(..., "ab12", "shop") ok; (..., "ab12", "other") and ("zz99","shop") -> ErrUnverifiable
+ // OpenKey: p1 signed 73 h ago -> ok for ab12/shop; for ab12/other -> ErrUnverifiable; a tampered wire -> ErrUnverifiable
}
```
@@ -302,7 +313,7 @@ func TestValidate(t *testing.T) {
func TestGetRefusesWhatItCannotVerifyButReturnsItsETag(t *testing.T) {
// seed `{` -> Get: nil, non-empty etag, errors.Is(err, ErrUnverifiable)
// seed an unsigned policy JSON -> same
- // seed p1 signed 73 h ago -> Get: nil, etag, ErrExpired; GetForUpdate -> the policy and its etag
+ // seed p1 signed 73 h ago -> Get: nil, etag, ErrExpired; GetForUpdate -> the policy, the stored bytes and its etag
// seed p1 for ab12/other at Key("ab12","shop") -> Get and GetForUpdate: ErrUnverifiable
}
func TestSizeForHonoursTheBinding(t *testing.T) {
@@ -326,15 +337,15 @@ func TestPresentListsPolicies(t *testing.T) { /* two seeded keys -> both in the
### 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/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, policy_expires_at`; count 58), `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"`.
+- Produces (pinned): `Route.EgressAllow string`, `Route.Size string`, `Route.PolicyExpiresAt int64` (`policy_expires_at INTEGER NOT NULL DEFAULT 0`, unix seconds, the `CertExpiresAt` convention; `0` = none recorded), `func (r *Registry) SetPolicy(host, egressAllow, size string, expiresAt int64) error`.
+- Upsert: all three columns follow `update_settings`' rule: a tenant change takes the incoming values (empty and `0` included); the same tenant keeps an existing value when the incoming one is `''` or `0`.
+- None of the three is in `Fingerprint()` (they change what root writes, not what the edge serves). All three are in `snapshotWhenSetCols`, so an unset column hashes nothing and a registry with no policy digests exactly as before.
+- Remote: `remoteRequest` gains `EgressAllow string`, `Size string` and `PolicyExpiresAt int64`; method name `"SetPolicy"`.
- [ ] **Step 1: Write the failing tests**
@@ -342,17 +353,18 @@ func TestPresentListsPolicies(t *testing.T) { /* two seeded keys -> both in the
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 ""
+ // new row: EgressAllow "", Size "", PolicyExpiresAt 0
// f0, s0 := claimFingerprints(t, reg)
- // SetPolicy(host, "10.0.5.20 fd00:5::20", "large") -> Get shows both
+ // SetPolicy(host, "10.0.5.20 fd00:5::20", "large", 1900000000) -> Get shows all three
// Fingerprint == f0 (edge reload not moved); SnapshotFingerprint != s0
- // Upsert same tenant without the fields -> both kept
- // SetPolicy(host, "", "") -> both cleared; SnapshotFingerprint == s0 exactly
+ // Upsert same tenant without the fields -> all three kept
+ // SetPolicy(host, "10.0.5.20 fd00:5::20", "large", 1900043200) -> SnapshotFingerprint moves (an expiry-only change)
+ // SetPolicy(host, "", "", 0) -> all 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)
+ // row for ab12/shop with SetPolicy("10.0.5.20","xlarge",1900000000); Upsert same host as ab12/other with no fields
+ // -> EgressAllow "", Size "", PolicyExpiresAt 0 (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):
@@ -361,9 +373,9 @@ func TestRemoteSetPolicy(t *testing.T) {
```
- [ ] **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 3: Implement** in the six places `registry.go:883-892` lists. `SetPolicy` is one `UPDATE routes SET egress_allow = ?, size = ?, policy_expires_at = ? 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, a size name and the verified record's expiry, `''`/`0` = none; root reads them (plans 2, 3) and treats an allow with no expiry or a past one as no allow; 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)`.
+- [ ] **Step 5: Commit** `registry: egress_allow, size and policy_expires_at columns (#328, #329)`.
---
@@ -453,10 +465,10 @@ func TestClassifyAppPolicyIsExact(t *testing.T) {
```go
// writers_test.go: the boundary of spec §3 kept by source. Walks every non-test .go file
// under the module root (go/parser, as apps_swap_constants_test.go does) and records each
-// call of apppolicy.PutIfMatch, apppolicy.PutIfMatchETag, apppolicy.Sign, apppolicy.Delete and
-// release.AppPolicyKey outside internal/apppolicy.
+// call of apppolicy.PutIfMatch, apppolicy.PutIfMatchETag, apppolicy.PutWire, apppolicy.Sign,
+// apppolicy.Delete and release.AppPolicyKey outside internal/apppolicy.
func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
- // PutIfMatch/PutIfMatchETag/Sign: only internal/console/apipolicy.go (the door, Task 8, and the re-sign pass, Task 9)
+ // PutIfMatch/PutIfMatchETag/PutWire/Sign: only internal/console/apipolicy.go (the door, Task 8, and the re-sign pass, Task 9)
// Delete: only internal/console/server.go (create-time clear)
// release.AppPolicyKey: only internal/release/trash.go, internal/edge/storedoor.go,
// internal/hostops/mutation_classifier.go
@@ -481,11 +493,11 @@ func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
- Test: `internal/provision/apppolicy_reconcile_test.go`
**Interfaces:**
-- Consumes: Task 3 (`apppolicy.Present`, `Get`, `ErrUnverifiable`, `ErrExpired`, `SizeFor`, `EgressColumn`, `release.ErrRefused`), Task 4 (`SetPolicy`, the two `Route` fields), `cfg.ReleasePub` (the box's `CARLOS_RELEASE_PUBKEY`, already in `ReconcileConfig`).
+- Consumes: Task 3 (`apppolicy.Present`, `Get`, `ErrUnverifiable`, `ErrExpired`, `SizeFor`, `EgressColumn`, `release.ErrRefused`), Task 4 (`SetPolicy`, the three `Route` fields), `apppolicy.Lifetime`, `cfg.ReleasePub` (the box's `CARLOS_RELEASE_PUBKEY`, already in `ReconcileConfig`).
- Produces:
- `ReconcileConfig.PolicyChanged func(host string)` (cross-plan: plan 3 sets it to the activator's `NoteSize`)
- `func PolicyReadStates() map[string]string` (cross-plan: plan 2 Task 11 reads it): a copy of the last pass's per-app problems, key `<sqid>/<app>`, value `policy unreadable`, `policy unverifiable` or `policy expired`; an app read cleanly (present or absent) has no entry. Replaced whole at the end of each pass, under a mutex.
- - (this plan's names) `type appPolicyCols struct { EgressAllow, Size string; Known bool }`
+ - (this plan's names) `type appPolicyCols struct { EgressAllow, Size string; ExpiresAt int64; Known bool }`
- `func policyColsFor(r policyRead, rec Record) appPolicyCols`
- `type policyRead struct { p *apppolicy.Policy; state policyState }` with `policyState` one of `policyKnown` (verified, or absent), `policyRefused`, `policyUnverifiable`, `policyExpired`, `policyTransient`
- `convergeOwned(reg, existing, rec, policy, policyKnown, ap appPolicyCols, dryRun)`; `mintHeld(..., policy, policyKnown, ap appPolicyCols)`; `swapImport(..., policy, ap appPolicyCols)`
@@ -493,9 +505,9 @@ func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
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).
- **Verify before converging** (spec §3 rev 8): every read is `apppolicy.Get(ctx, s, cfg.ReleasePub, sqid, app, cfg.Now())`, so a column is only ever written from a record the release key signed for this account and app and that has not expired. A nil `ReleasePub` makes every present policy `policyUnverifiable`.
-- **States to columns:** `policyKnown` → `{p.EgressColumn(), p.SizeFor(rec.Host, rec.CreatedAt), true}` (nil `p` gives `{"", "", true}`). `policyRefused`, `policyUnverifiable` and `policyExpired` → `{"", "", true}`: fail closed (no allow; sizes at the build's tier from the next start, which plan 3 never applies as a live memory shrink), log `provision: %s/%s: policy <word>: %v` once per app per pass with `<word>` `unreadable`, `unverifiable` or `expired`, and record the word in `PolicyReadStates`. `policyTransient` → `Known: false`: existing rows keep their columns; a new row is minted with `''` (no allow, the build's tier) and converges on the next readable pass. A mint never waits for the policy (unlike the update policy).
-- **Scope:** instance rows from `Reconcile` only. Sidecar members (`Kind "sidecar"` rows from `ReconcileSidecars`, `*.sidecar.internal`) get no policy: both columns stay `''` (spec §4.3 rev 8; plan 2 counts any such row as making the box not wholly nested, so no allow is written on that box at all). `app_id` is not checked on this path (no store-visible incarnation before claims; spec §3).
-- **Converge** both columns in one `SetPolicy` when either differs, dry-run aware, logging `converged %s policy egress %q -> %q, size %q -> %q` in the style of the update-settings arm, then call `cfg.PolicyChanged(host)` when it is set.
+- **States to columns:** `policyKnown` → `{p.EgressColumn(), p.SizeFor(rec.Host, rec.CreatedAt), p.SignedAt.Add(apppolicy.Lifetime).Unix(), true}` (nil `p` gives `{"", "", 0, true}`). `policyRefused`, `policyUnverifiable` and `policyExpired` → `{"", "", 0, true}`: fail closed (no allow; sizes at the build's tier from the next start, which plan 3 never applies as a live memory shrink), log `provision: %s/%s: policy <word>: %v` once per app per pass with `<word>` `unreadable`, `unverifiable` or `expired`, and record the word in `PolicyReadStates`. `policyTransient` → `Known: false`: an existing row keeps its columns **while its `PolicyExpiresAt` is in the future**; a row whose recorded expiry has passed (or is `0` with a non-empty column) is cleared as `policyExpired` is (log and state `expired`), so a store outage longer than the lifetime closes the allow even if root never reads the store (Astra plan R1-3; root enforces the same expiry itself, plan 2 Task 5). A new row is minted with `''`/`0` (no allow, the build's tier) and converges on the next readable pass. A mint never waits for the policy (unlike the update policy).
+- **Scope:** instance rows from `Reconcile` only. Sidecar members (`Kind "sidecar"` rows from `ReconcileSidecars`, `*.sidecar.internal`) get no policy: the columns stay `''` and `0` (spec §4.3 rev 8; plan 2 counts any such row as making the box not wholly nested, so no allow is written on that box at all). `app_id` is not checked on this path (no store-visible incarnation before claims; spec §3).
+- **Converge** all three columns in one `SetPolicy` when any differs, dry-run aware, logging `converged %s policy egress %q -> %q, size %q -> %q` in the style of the update-settings arm (an expiry-only change, which the console's 12-hour re-sign causes, logs nothing), then call `cfg.PolicyChanged(host)` when it is set and the egress or size column changed.
- [ ] **Step 1: Write the failing tests**
@@ -506,9 +518,11 @@ func TestReconcileCarriesThePolicyToTheRow(t *testing.T) {
// cfg.ReleasePub = the test key's pub; cfg.PolicyChanged records hosts
// policy {V:1, Account, App, Revision:1, SignedAt:now, EgressAllow:["10.0.5.20","fd00:5::20"],
// DefaultSize:"large"} put with PutIfMatch(ctx, s, priv, p, "")
- // one pass mints the row with EgressAllow "10.0.5.20 fd00:5::20", Size "large"
+ // one pass mints the row with EgressAllow "10.0.5.20 fd00:5::20", Size "large",
+ // PolicyExpiresAt == now.Add(apppolicy.Lifetime).Unix()
// override shop-a -> {xlarge, t0} (Revision 2); next pass -> Size "xlarge"; PolicyChanged saw shop-a once
- // apppolicy.Delete; next pass -> both ""; PolicyReadStates() has no entry for the app
+ // the same content re-signed 12 h later -> PolicyExpiresAt moves; PolicyChanged not called again
+ // apppolicy.Delete; next pass -> both "", PolicyExpiresAt 0; PolicyReadStates() has no entry for the app
}
func TestReconcileVerifiesBeforeConverging(t *testing.T) {
// start from a converged row ("10.0.5.20", "large"), then replace the object with:
@@ -528,9 +542,11 @@ func TestReconcileIgnoresAnOverrideForAnotherIncarnation(t *testing.T) {
func TestReconcilePolicyReadFailures(t *testing.T) {
// start from a converged row ("10.0.5.20", "large"), then wrap the DirStore:
// transient: Get of the policy key returns a plain error and List of Prefix fails -> row unchanged
+ // still transient, cfg.Now() advanced past the row's PolicyExpiresAt (an outage longer than
+ // apppolicy.Lifetime) -> row "" "" 0, state "policy expired" (Astra plan R1-3)
// refused: Get returns fmt.Errorf("x: %w", release.ErrRefused) -> row "" "", state "policy unreadable"
// unverifiable: the object's bytes are `{` -> row "" ""
- // a new record minted during the transient pass -> its row is "" "" and converges on the next good pass
+ // a new record minted during the transient pass -> its row is "" "" 0 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
@@ -550,7 +566,9 @@ func TestReconcileDryRunWritesNoPolicy(t *testing.T) { /* cfg.DryRun: row column
**Files:**
- Create: `internal/console/apipolicy.go`
-- Create: `internal/console/apppolicy_store.go` (the revision high-water table, in the shape of `door_key_revisions`, `doorkeys_store.go:33-70`)
+- Create: `internal/console/apppolicy_store.go` (the revision and recorded-content table, in the shape of `door_key_revisions`, `doorkeys_store.go:33-70`, with its writes as `claimRaw*` methods behind `withAutoAppSQL` adapters, the `DoorKeyAdvanceRevisionContext` shape at `doorkeys_store.go:236-250`)
+- Modify: `internal/console/store_gate.go` (`claimSQLTables`, ~193: classify `app_policy_revisions` beside `door_key_revisions`; `claimSQLCensus` at ~307 refuses every unclassified table, and `OpenGated` returns that refusal at ~1854)
+- Modify: `internal/console/store_gate_test.go` (`TestClaimSQLTableCensus`, ~720: the table joins the must-be-gated list)
- 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)
@@ -559,7 +577,12 @@ func TestReconcileDryRunWritesNoPolicy(t *testing.T) { /* cfg.DryRun: row column
**Interfaces:**
- Consumes: Tasks 1-3, 6; `s.cfg.Key` (the release signing key, as `apidoorkeys.go:142` signs with it) and `doorKeyWritable` (`apidoorkeys.go:184`, its approved 503 messages reused).
- Produces:
- - `CREATE TABLE IF NOT EXISTS app_policy_revisions (account_id INTEGER NOT NULL, app TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY (account_id, app))`; `func (s *Store) AppPolicyRevisionContext(ctx context.Context, accountID int64, app string) (uint64, error)` (0 when no row); `func (s *Store) RaiseAppPolicyRevisionContext(ctx context.Context, accountID int64, app string, rev uint64) error` (`revision = MAX(revision, rev)`). The purge leaves the row: revisions only rise, so a recreated app's first policy is above every record its predecessor had. This is the doorkey database counter's job (`apidoorkeys.go:120-133`): a bucket writer can corrupt the object and lose its revision, or put an older signed record back, and the console must neither sign below a revision it already signed nor build on a record older than one it wrote.
+ - `CREATE TABLE IF NOT EXISTS app_policy_revisions (account_id INTEGER NOT NULL, app TEXT NOT NULL, revision INTEGER NOT NULL, wire BLOB NOT NULL, PRIMARY KEY (account_id, app))`, classified in `claimSQLTables` as `{Account: "account_id", App: "app"}` (gated, app-scoped; the purge never writes it, so no `PurgeMonotone`).
+ - `func (s *Store) AppPolicyRecordContext(ctx context.Context, accountID int64, app string) (rev uint64, wire []byte, err error)`: the recorded revision and the signed bytes recorded at it; `0, nil, nil` when no row.
+ - `func (s *Store) RecordAppPolicyContext(ctx context.Context, accountID int64, app string, rev uint64, wire []byte) error`: one transaction that sets `revision = rev, wire = wire` only when `rev` is above the row's revision (inserting when there is none), else `errAppPolicyRevisionUsed`. It is both the counter bump and the content record, so a revision number is never recorded twice and never names two contents.
+ - `func (s *Store) ResignAppPolicyContext(ctx context.Context, accountID int64, app string, rev uint64, wire []byte) error`: replaces `wire` only where `revision = rev` (the re-sign: same revision, same content, a later `signed_at`), else `errAppPolicyRevisionMoved`.
+ - Each is a `claimRaw*` method plus its `withAutoAppSQL` adapter (`"sql.RecordAppPolicy"`, `"sql.ResignAppPolicy"`), the only path the armed gate admits.
+ - The table is the authority for content once a revision is recorded, as the door keys' database is (`doorkeys_store.go:3-11`): the door records the signed bytes **before** the bucket write, so a write whose reply is lost, or a bucket writer that puts an older record back, can never leave the console building on, or re-signing, content older than what it last signed (Astra plan R1-1). The purge leaves the row: revisions only rise, so a recreated app's first policy is above every record its predecessor had. The recorded content is used only while the bucket holds an object at the key: an absent object (a purge, a create-time clear, or a first write that never landed) starts empty, so a purged app's content is never brought back.
- `func (s *Server) policyLock(sqid, app string) *sync.Mutex` (the app's policy mutex, created on first use as `doorPublishLock` is; Task 9 shares it)
- `GET /api/cli/apps/{account}/{app}/policy` → `handleAPIPolicyGet`; `PUT` same path → `handleAPIPolicyPut`. Both `svcrole.Set{}`.
- Request (strict reader, 8 KiB): `type policyPutReq struct { EgressAllow *[]string `json:"egress_allow"`; DefaultSize *string `json:"default_size"`; Size *policySizeReq `json:"size"` }`, `type policySizeReq struct { Host string `json:"host"`; Value string `json:"value"` }`. `null` or absent leaves a field; `[]` clears the allow; `""` clears the default size; `value: "default"` removes the override.
@@ -567,13 +590,18 @@ func TestReconcileDryRunWritesNoPolicy(t *testing.T) { /* cfg.DryRun: row column
- `checkEgressGrant(ctx, acct, app, old, next []string)` (stub; see "Interfaces produced"), `auditRequired(ctx context.Context, acct *Account, app, addr, detail string) error` (wraps `s.store.RecordEventContext`, action `"app-policy"`).
PUT order (each refusal writes nothing):
-1. `apiSystemRoller` (bare 404). 2. `AccountBySqid(r.PathValue("account"))`, then `HasAppIn`: either missing → bare 404. 3. `allowWrite` → 429 `apiSwapLimited`. 4. `doorKeyWritable`: no signing key or no bucket → 503 with its message. 5. Body; nothing set → 400. 6. Precondition: `If-Match: <etag>` or `If-None-Match: *` (= `""`); neither → 428. 7. Take `policyLock(acct.Sqid, app)` until the reply. `apppolicy.GetForUpdate(ctx, s.cfg.Releases, pub, acct.Sqid, app)` and `AppPolicyRevisionContext` (`h`): absent → `{V:1}`, `""`, stored revision 0; a signed record (expired or not) whose revision is at least `h` → it, its ETag, its revision; `ErrUnverifiable`, or a signed record below `h` (an older record put back by a bucket writer) → `{V:1}` with the returned ETag and stored revision 0 (the operator's repair path; logged `console: app policy %s/%s: not building on the stored object: %v`); other error → 502. The precondition must equal the current ETag, else 412. `next := max(stored revision, h) + 1`. 8. Apply: `egressaddr.Normalize` (400 with its message); `default_size` via `cgroup.SizeByName` or `""`; `size`: `ValidHostShape` (400 `invalid host`), `provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, strings.ToLower(host))` (absent → 404 `no instance %s in this app`), then set `{value, rec.CreatedAt}` or delete. Drop every other override whose record is absent or whose `CreatedAt` differs (the spec's "removes the entry, for tidiness", done here so no member door writes the object). 9. With `s.cfg.HostClaim`, `p.AppID = s.store.ClaimAppID(acct.ID, app)` (error → 503); without, `AppID` stays as read (empty). 10. `checkEgressGrant(ctx, acct, app, old.EgressAllow, p.EgressAllow)` whenever `egress_allow` was sent. 11. `s.appMutation(ctx, acct, app, "app-policy", ...)`: inside, `auditRequired` first (failure → 503, no write), then set `p.Account, p.App, p.Revision, p.SignedAt = acct.Sqid, app, next, time.Now().UTC()` and `PutIfMatchETag(ctx, s.cfg.Releases, s.cfg.Key, p, etag)`; `ErrCASConflict` → 412; other → 502, and a best-effort `s.audit(..., "app-policy-not-applied", ...)` row. After a successful write, `RaiseAppPolicyRevisionContext(next)` (a failure is logged and the write stands: the bucket's revision then leads, and Task 9 and the next write raise the table past it; raising before the write would leave the table above a write that failed, and Task 9 would then refuse to re-sign the live record). 12. `s.bumpSerial(ctx)`. 13. 200 with the reply.
+1. `apiSystemRoller` (bare 404). 2. `AccountBySqid(r.PathValue("account"))`, then `HasAppIn`: either missing → bare 404. 3. `allowWrite` → 429 `apiSwapLimited`. 4. `doorKeyWritable`: no signing key or no bucket → 503 with its message. 5. Body; nothing set → 400. 6. Precondition: `If-Match: <etag>` or `If-None-Match: *` (= `""`); neither → 428. 7. Take `policyLock(acct.Sqid, app)` until the reply. Read the bucket with `apppolicy.GetForUpdate(ctx, s.cfg.Releases, pub, acct.Sqid, app)` (`b`, its bytes, `etag`) and the table with `AppPolicyRecordContext` (`h`, `rw`); a read error of either other than absence or `ErrUnverifiable` → 502. Choose the base, in order:
+ - the bucket object is absent → `{V:1}`, `etag ""` (Task 9 never revives an absent object, so neither does the door);
+ - `b` verified at a revision above `h` (a database restored from backup, or a record this console signed and then lost the row of) → adopt it: `RecordAppPolicyContext(b.Revision, its bytes)`, base `b`;
+ - `h > 0` → base `OpenKey(pub, rw, acct.Sqid, app)` (the recorded content; an error here → 502), whatever the bucket holds; when the bucket's object is unverifiable, below `h`, or at `h` with other content (`rw`'s payload differs), log `console: app policy %s/%s: not building on the stored object: %v` (the operator's repair path);
+ - no row and `b` verified → adopt it as above; no row and `ErrUnverifiable` → `{V:1}` with the object's ETag.
+ With `s.cfg.HostClaim`: `cur := s.store.ClaimAppID(acct.ID, app)` (error → 503); a base whose `AppID` is not `cur` (a predecessor's policy a failed create-time clear left, Task 10) is replaced by `{V:1}` **before** the change is applied, keeping the object's ETag and the revision floor, logged `console: app policy %s/%s: stored policy is for incarnation %q, not %q; starting empty` (Astra plan R1-2). The precondition must equal the bucket's current ETag, else 412. `next := max(h, revision of a verified b) + 1`. `old` (for step 10 and the audit) is the base after these rules. 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 = cur` (step 7); without, `AppID` stays as read (empty). 10. `checkEgressGrant(ctx, acct, app, old.EgressAllow, p.EgressAllow)` whenever `egress_allow` was sent. 11. `s.appMutation(ctx, acct, app, "app-policy", ...)`: inside, `auditRequired` first (failure → 503, no write); then set `p.Account, p.App, p.Revision, p.SignedAt = acct.Sqid, app, next, time.Now().UTC()`, `wire := apppolicy.Sign(s.cfg.Key, p)`, and **record before publishing**: `RecordAppPolicyContext(ctx, acct.ID, app, next, wire)` (failure → 503 `the change could not be recorded, so it was not made. Try again`, no bucket write). Then `apppolicy.PutWire(ctx, s.cfg.Releases, acct.Sqid, app, wire, etag)`: `ErrCASConflict` (the object changed after step 7, which under the lock only a bucket writer can do: two operators are serialised by the lock and refused at the precondition) → re-read the ETag and put the same bytes again, up to `doorKeyPublishAttempts` (the door keys' publish loop, `apidoorkeys.go:106-155`); any other failure, or the attempts spent → 502 `the change is recorded but not yet published. Run the command again to publish it now` (the door keys' 502 precedent, `apidoorkeys.go:321`). The change stands once recorded: Task 9's pass (at console start and every 12 h) publishes the recorded content over whatever an existing object holds, and the next operator write builds on it. 12. `s.bumpSerial(ctx)`. 13. 200 with the reply.
Audit detail (copy, Task 14): the changed parts joined with `"; "`, each `egress allow: <old> -> <new>`, `default size: <old> -> <new>`, `size of <host>: <old> -> <new>`, with `none`, `the build's size` and `the app default` for empty values, ending ` (cli)`.
-GET: steps 1-2, then `GetForUpdate` and the high-water check as in step 7; unverifiable or below the high-water mark → 200 with `{V:1}`, the ETag, and `stale_overrides` empty (logged). An expired record is shown as stored (its `signed_at` says when). `Stale` lists override hosts whose record is absent or whose `CreatedAt` differs.
+GET: steps 1-2, then step 7's reads and base rules without writing (no adoption is recorded on a GET); 200 with the base, the bucket's ETag, and `stale_overrides` (empty for `{V:1}`). So the GET shows exactly what a PUT would build on: the recorded content, `{V:1}` for an absent or unusable object, `{V:1}` for another incarnation's policy under claims. An expired record is shown as stored (its `signed_at` says when). `Stale` lists override hosts whose record is absent or whose `CreatedAt` differs.
-Door messages (copy, Task 14): `nothing to set: send egress_allow, default_size or size`; `default_size must be small, standard, large or xlarge, or empty for the build's size`; `size must be small, standard, large, xlarge or default`; `no instance %s in this app`; `send If-Match with the policy's etag, or If-None-Match: * when there is none`; `the policy changed since it was read. Read it again and retry`; `the change could not be recorded, so it was not made. Try again`; `could not save the policy. Try again`; `could not read the policy. Try again`.
+Door messages (copy, Task 14): `nothing to set: send egress_allow, default_size or size`; `default_size must be small, standard, large or xlarge, or empty for the build's size`; `size must be small, standard, large, xlarge or default`; `no instance %s in this app`; `send If-Match with the policy's etag, or If-None-Match: * when there is none`; `the policy changed since it was read. Read it again and retry`; `the change could not be recorded, so it was not made. Try again`; `the change is recorded but not yet published. Run the command again to publish it now`; `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)`)
@@ -615,10 +643,40 @@ func TestPolicyPutRefusesWhenTheAuditFails(t *testing.T) {
}
func TestPolicyPutSignsOneRevisionAboveTheCurrent(t *testing.T) {
// PUT -> the stored bytes pass apppolicy.Verify(fx's release pub, wire, now) and VerifyKey for this
- // account and app; Revision 1; SignedAt within the test's window; AppPolicyRevisionContext == 1
+ // account and app; Revision 1; SignedAt within the test's window;
+ // AppPolicyRecordContext == (1, the stored bytes exactly)
// second PUT -> Revision 2
- // RaiseAppPolicyRevisionContext(7) by hand -> next PUT Revision 8
- // a record signed by the fixture's key at Revision 9 put at the key directly -> next PUT Revision 10, table 10
+ // RecordAppPolicyContext(7, a record of the same content at 7) by hand -> next PUT Revision 8
+ // a record signed by the fixture's key at Revision 9 put at the key directly -> next PUT builds on it, Revision 10, table 10
+}
+func TestAppPolicyRevisionsAreNeverReused(t *testing.T) {
+ // RecordAppPolicyContext(2, w2) -> ok; RecordAppPolicyContext(2, w2') and (1, w1) -> errAppPolicyRevisionUsed,
+ // the row still (2, w2)
+ // ResignAppPolicyContext(2, w2 re-signed) -> ok; ResignAppPolicyContext(1, ...) -> errAppPolicyRevisionMoved
+}
+func TestPolicyRevokeSurvivesALostWriteAndAReplay(t *testing.T) {
+ // Astra plan R1-1. PUT egress ["10.0.5.20"] -> rev 1; keep the stored bytes w1
+ // a Releases wrapper makes the next Put of the policy key write the object and then return an error
+ // (the bucket write landed, everything after it failed)
+ // PUT egress [] -> 502 with the not-yet-published message; AppPolicyRecordContext == rev 2 with no egress_allow
+ // put w1 back at the key (a bucket writer restoring the older object)
+ // resignAppPolicies -> the stored object verifies at Revision 2 with no egress_allow (the interrupted publication finished)
+ // put w1 back again; GET -> no egress_allow; PUT If-Match <its etag> {"default_size":"small"} -> 200,
+ // stored Revision 3, no egress_allow, default_size small
+}
+func TestPolicyPutStartsEmptyOverAnotherIncarnationsPolicy(t *testing.T) {
+ // Astra plan R1-2. claim-enabled fixture (newClaimCase): PUT egress ["10.0.5.20"] -> app_id a1, rev 1
+ // purge and recreate the app through the doors with a Releases wrapper failing Delete of the policy key
+ // (Task 10's create-time clear fails and the create stands); ClaimAppID is now a2
+ // GET -> {v:1}, no egress_allow
+ // PUT If-Match <the object's etag> {"default_size":"small"} -> stored app_id a2, Revision 2,
+ // no egress_allow, default_size small; the audit detail has no "egress allow" part
+}
+func TestOpenGatedClassifiesAppPolicyRevisions(t *testing.T) {
+ // Astra plan R1-4. openGatedStore(t) succeeds (OpenGated runs the migration and the census);
+ // claimSQLCensus(s.bootDB(), claimSQLTables) lists app_policy_revisions as gated (NonApp == "");
+ // an outer db.Exec INSERT into it on the armed store is refused; RecordAppPolicyContext through a
+ // claim-enabled fixture's admission seam lands
}
func TestPolicyPutDoesNotBuildOnAReplayedRecord(t *testing.T) {
// PUT egress ["10.0.5.20"] (rev 1, wire w1); PUT egress [] (rev 2); put w1 back at the key
@@ -640,8 +698,8 @@ func TestPolicyPutStampsTheAppIDUnderClaims(t *testing.T) {
}
```
-- [ ] **Step 2: Run, fail:** `go test ./internal/console -run 'TestPolicy|TestClaimConsoleMutationInvariant|TestNoRoleReaches|TestAdminIsRefusedAtAClosedDoor'`. Expected: the new tests fail; the census tests fail on the unregistered routes once registered.
-- [ ] **Step 3: Implement** `apppolicy_store.go` (migration beside the door-key tables' and the two methods), `apipolicy.go` (file comment: spec §6, operator-only, why the zero Set, why the audit is not best-effort, why the door signs with a revision above both the bucket's and the table's), the registration (comment: `apiSystemRoller` in the handler, the zero Set at the mux), the `closedDoors` lines, the claim fixture (`apps: claimRefs("bab/titogo")`, a PUT with `If-None-Match: *` and a body setting `default_size`; add a header-capable request helper next to `claimAPI` if none exists) and the read probe.
+- [ ] **Step 2: Run, fail:** `go test ./internal/console -run 'TestPolicy|TestAppPolicyRevisions|TestOpenGatedClassifiesAppPolicyRevisions|TestClaimSQLTableCensus|TestClaimSQLSeamCallers|TestClaimConsoleMutationInvariant|TestNoRoleReaches|TestAdminIsRefusedAtAClosedDoor'`. Expected: the new tests fail; the census tests fail on the unregistered routes once registered; with the migration in and the table not yet classified, every gated open fails with `table "app_policy_revisions" is not classified in claimSQLTables`.
+- [ ] **Step 3: Implement** `apppolicy_store.go` (migration beside the door-key tables' and the three methods with their adapters), the `claimSQLTables` entry, `apipolicy.go` (file comment: spec §6, operator-only, why the zero Set, why the audit is not best-effort, why the door signs with a revision above both the bucket's and the table's and records the signed bytes before it publishes them), 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)`.
@@ -656,29 +714,41 @@ func TestPolicyPutStampsTheAppIDUnderClaims(t *testing.T) {
- Test: `internal/console/apipolicy_resign_test.go`
**Interfaces:**
-- Consumes: Task 3 (`Present`, `Key`, `GetForUpdate`, `PutIfMatchETag`, `ErrUnverifiable`), Task 8 (`policyLock`, `AppPolicyRevisionContext`, `RaiseAppPolicyRevisionContext`), `s.appMutation`, `AccountBySqid`, `doorKeyPublishBudget`.
+- Consumes: Task 3 (`Present`, `Key`, `GetForUpdate`, `OpenKey`, `Sign`, `PutWire`, `ErrUnverifiable`), Task 8 (`policyLock`, `AppPolicyRecordContext`, `RecordAppPolicyContext`, `ResignAppPolicyContext`), `s.appMutation`, `AccountBySqid`, `doorKeyPublishBudget`.
- Produces: `func (s *Server) resignAppPolicies(ctx context.Context)`; `func (s *Server) resignAppPolicy(ctx context.Context, acct *Account, app string) error`.
-Rules (the door keys' re-sign, `apidoorkeys.go:328-365`, with the bucket as the content's source):
-- Runs only where `StartDoorKeyResign` runs (a signing key and a bucket): at start, then every `doorKeyResignEvery` (12 h). A record lives `Lifetime` (72 h), so a console down for up to 60 h past its last pass leaves no policy expired; one down longer closes every granted address until it is back (spec §3, the door keys' trade), and its first pass re-signs them.
-- `Present` lists the keys; each must be exactly `Key(sqid, app)` with a valid account and app, else skipped. `AccountBySqid` missing → logged and skipped (the purge sweep owns it).
-- Per app, under `policyLock` and `s.appMutation(ctx, acct, app, "app-policy-resign", ...)`, within `doorKeyPublishBudget`: `GetForUpdate`. Absent → nothing. `ErrUnverifiable` → logged `console: app policy %s/%s: not re-signing an object that does not verify; an operator write repairs it`, nothing written (the console never signs bytes it cannot verify). With `h` from `AppPolicyRevisionContext`: revision below `h` → logged as replayed, nothing written (roots refuse it too, plan 2); revision above `h` → `RaiseAppPolicyRevisionContext(revision)` first. Then the same content at the **same revision** with `SignedAt = now`, `PutIfMatchETag(..., etag)` (replace-only, so a purge that deleted the key between the read and the write is never undone; `ErrCASConflict` and a not-found are both logged and skipped). The same revision is safe to re-sign: an older signing of it carries the same content with an earlier expiry, so replaying it gains nothing.
+Rules (the door keys' re-sign, `apidoorkeys.go:328-365`, with the console's table as the content's source, Astra plan R1-1):
+- Runs only where `StartDoorKeyResign` runs (a signing key and a bucket): at start, then every `doorKeyResignEvery` (12 h). A record lives `Lifetime` (72 h), so a console down for up to 60 h past its last pass leaves no policy expired; one down longer closes every granted address until it is back (spec §3, the door keys' trade), and its first pass re-signs them. The pass at start is also the restart recovery: it finishes any publication a crash or a lost write interrupted.
+- `Present` lists the keys; each must be exactly `Key(sqid, app)` with a valid account and app, else skipped. `AccountBySqid` missing → logged and skipped (the purge sweep owns it). Only listed (present) objects are visited, so an absent object is never recreated (purge, create-time clear).
+- Per app, under `policyLock` and `s.appMutation(ctx, acct, app, "app-policy-resign", ...)`, within `doorKeyPublishBudget`: `GetForUpdate` (`b`, bytes, `etag`) and `AppPolicyRecordContext` (`h`, `rw`). Absent → nothing.
+ - `b` verified at a revision above `h` → `RecordAppPolicyContext(b.Revision, bytes)` first (adopt, as the door does), then re-sign it as below.
+ - `h > 0` → the content to sign is `OpenKey(pub, rw, ...)`, **never the bucket's**: the bucket object may be the recorded revision, an interrupted publication's predecessor, a replayed older record or unverifiable bytes. When it is anything but the recorded content, log `console: app policy %s/%s: the bucket held revision %d, not the recorded %d; publishing the recorded one`.
+ - no row and `b` verified → adopt it (`RecordAppPolicyContext`), then re-sign it. No row and `ErrUnverifiable` → logged `console: app policy %s/%s: not re-signing an object that does not verify; an operator write repairs it`, nothing written (the console never signs bytes it neither recorded nor verified).
+ - Re-sign: the recorded content at the **recorded revision** with `SignedAt = now`; `ResignAppPolicyContext(h, wire')` before the write (the record never lags the bucket); then `PutWire(..., wire', etag)` (replace-only on the ETag just read, so a purge that deleted the key between the read and the write is never undone; `ErrCASConflict` and a not-found are both logged and skipped). The same revision is safe to re-sign: every signing of it carries the same content with a different expiry, so replaying an older one gains nothing.
- A failure for one app is logged and does not stop the others.
-- [ ] **Step 1: Write the failing tests** (`t.Parallel()`; the fixture's release key; records written with `PutIfMatch` directly)
+- [ ] **Step 1: Write the failing tests** (`t.Parallel()`; the fixture's release key; records written with `PutIfMatch` directly, and recorded with `RecordAppPolicyContext` where a test says "recorded")
```go
func TestResignRefreshesSignedAtAndKeepsTheRevision(t *testing.T) {
- // a record at Revision 3 signed 50 h ago -> after resignAppPolicies: Verify ok at now+71h,
- // Revision 3, content equal, SignedAt within the test's window
+ // a record at Revision 3 signed 50 h ago, recorded -> after resignAppPolicies: Verify ok at now+71h,
+ // Revision 3, content equal, SignedAt within the test's window; the recorded bytes equal the stored bytes
}
func TestResignRevivesAnExpiredRecord(t *testing.T) {
- // signed 80 h ago (Verify -> ErrExpired) -> after the pass Verify ok
+ // signed 80 h ago (Verify -> ErrExpired), recorded -> after the pass Verify ok
+}
+func TestResignPublishesOnlyTheRecordedContent(t *testing.T) {
+ // app a: `{` at its key, nothing recorded -> byte-identical after the pass
+ // app b: Revision 4 recorded with no egress_allow, a Revision 1 record granting 10.0.5.20 at the key
+ // -> after the pass the object verifies at Revision 4 with no egress_allow
+ // app c: `{` at its key, Revision 2 recorded -> the object verifies at Revision 2 with the recorded content
+ // app d: a Revision 6 record at the key, Revision 2 recorded -> re-signed at 6, the table now (6, its content)
+ // app e: Revision 5 recorded with egress [] and a Revision 5 record granting 10.0.5.20 at the key
+ // (other content at the same revision) -> the object carries the recorded content, never the bucket's
}
-func TestResignLeavesUnverifiableAndReplayedObjectsAlone(t *testing.T) {
- // app a: `{` at its key; app b: a Revision 1 record with the table at 4; app c: a valid record
- // -> a's and b's objects byte-identical after the pass; c re-signed
- // app d: a Revision 6 record with the table at 2 -> re-signed at 6, table raised to 6
+func TestResignFinishesAnInterruptedPublication(t *testing.T) {
+ // Revision 2 (revoke) recorded, the key still holding Revision 1 (allow), as after a crash between
+ // RecordAppPolicyContext and the write -> StartDoorKeyResign's first pass leaves Revision 2 at the key
}
func TestResignNeverRecreatesAPurgedPolicy(t *testing.T) {
// a store wrapper that deletes the key between GetForUpdate's Get and the Put -> the key is absent after the pass
@@ -690,7 +760,7 @@ func TestStartDoorKeyResignAlsoResignsPolicies(t *testing.T) {
```
- [ ] **Step 2: Run, fail:** `go test ./internal/console -run 'TestResign|TestStartDoorKeyResignAlsoResignsPolicies|TestClaimConsoleMutationInvariant'`.
-- [ ] **Step 3: Implement.** Comment on `resignAppPolicies`: why the content comes from the bucket (the object is the authority, spec §3) while the revision floor comes from the table (Task 8).
+- [ ] **Step 3: Implement.** Comment on `resignAppPolicies`: why the content and the revision both come from the table (Task 8; the door keys' database-as-authority rule, Astra plan R1-1), why only present objects are visited (an absent one is a purge or a clear, never revived), and why adopting a higher bucket revision is safe (only a console with the release key can have signed it).
- [ ] **Step 4: Run, pass.** Same command, plus `go test ./internal/console -run 'TestDoorKey'` (the door keys' own pass is unchanged).
- [ ] **Step 5: Commit** `console: re-sign every app policy on the door keys' loop (#328, #329)`.
@@ -705,7 +775,7 @@ func TestStartDoorKeyResignAlsoResignsPolicies(t *testing.T) {
**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).
+- 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, and the door starts such a policy empty (Task 8 step 7, Astra plan R1-2). Both apply to the owner's adopt-existing case too (fail closed: the operator grants again).
- [ ] **Step 1: Write the failing tests**
@@ -826,7 +896,7 @@ This plan changes no unit, slice or probe line. Its byte-identity surface is the
```go
func TestNoPolicyLeavesRowsAndFingerprintsAsMain(t *testing.T) {
// fixture with two apps, records, no policy objects. Pass 1 mints.
- // Every row: EgressAllow == "" && Size == "".
+ // Every row: EgressAllow == "" && Size == "" && PolicyExpiresAt == 0.
// 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.
@@ -873,7 +943,7 @@ If another overlay lands on main first, load this one after it and say so in bot
- 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 11's table, the changed `appsSetNothing`, the `appsUsage` `show` line and `appsSetPolicyUsage`; the README entry. Count each string's occurrences at the base commit first (memory: shared strings need their own review).
+- [ ] **Step 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 new 502 `the change is recorded but not yet published. Run the command again to publish it now`); Task 11's table, the changed `appsSetNothing`, the `appsUsage` `show` line and `appsSetPolicyUsage`; the README entry. Count each string's occurrences at the base commit first (memory: shared strings need their own review).
- [ ] **Step 2: Run the copy-review skill** with that list; wait for Paul's edits.
- [ ] **Step 3: Apply the edits verbatim**, rerun `go test . -run 'TestApprovedCopy|TestAppsSet|TestAppsShow|TestChangeAppPolicy'`, `go test ./internal/egressaddr`, `go test ./internal/console -run TestPolicy`.
- [ ] **Step 4: Add the approved-copy test** pinning the reviewed strings; run it.
@@ -886,3 +956,14 @@ If another overlay lands on main first, load this one after it and say so in bot
- **Spec coverage (plan 1's share):** §3 signed record (domain, revision one above the current, 72 h lifetime, re-sign on the door keys' loop, the agent verifying before converging) → Tasks 3, 7, 8, 9; sidecar members get no policy (§4.3 rev 8) → Task 7; no below-tier warning (§5.2 rev 8) → Task 11 says so. §3 object, CAS, `CreatedAt` binding, `app_id` stamp under claims, purge sweep before the marker, create-time clear, tidy removal (done at the next operator write instead of at instance delete, so no member door writes the object), precondition (recorded above, Task 6) → Tasks 3, 6, 8, 9, 10. §4.1 address rules → Task 1. §4.3 before-claims delivery and Transport → Tasks 5, 7. §5.1 names → Task 2. §5.2 resolution of the configured part → Tasks 3, 7. §6 door, roles, mandatory audit → Task 8. §2 CLI goals → Task 11. §7 rollout note → Task 14. "Nothing set" → Task 12. Not here by design: §4.2 checks (plan 2, stub in Task 8), root's reads and writes, read-back and display columns (plans 2, 3), §5.3-5.6 (plan 3).
- **Names:** the brief's pinned names (rev 8: `Get`, `PutIfMatch` and `Policy` changed for the signature, and the brief was updated with them) are used unchanged; additions are listed under "Interfaces produced". `apppolicy.Prefix`/`Key` are defined through `release` (cycle), with the same values.
- **Decisions the spec did not pin:** see the report that accompanies this plan.
+- **Astra plan R1 re-check:** the self-review was run again after the dispositions below. Spec coverage is unchanged (the table now records content as the door keys' database does, spec §3's precedent; the expiry column carries §3's lifetime to the box before claims). Names: `GetForUpdate` gains the bytes, `OpenKey`, `PutWire`, `AppPolicyRecordContext`, `RecordAppPolicyContext`, `ResignAppPolicyContext` and `Route.PolicyExpiresAt` are used the same way in Tasks 3, 4, 7, 8 and 9 and in the brief; `RaiseAppPolicyRevisionContext` and `AppPolicyRevisionContext` are gone everywhere. Copy: one new door message (the 502), and the `app-policy-not-applied` row is gone.
+
+## Review dispositions (Astra plan R1)
+
+| Finding | What changed |
+|---|---|
+| R1-1 (P1) lost counter write resurrects a revoke | Task 8: `app_policy_revisions` now records the signed bytes with the revision, in one transaction (`RecordAppPolicyContext`), **before** the bucket write; the door builds on the recorded content whenever an object exists; a revision is never recorded twice. Task 9: the pass signs only the recorded content (never the bucket's), adopts only a higher verified bucket revision, and so finishes an interrupted publication at start and every 12 h. A publication failure is now a 502 "recorded but not yet published" (the door keys' precedent). Tests: `TestPolicyRevokeSurvivesALostWriteAndAReplay`, `TestAppPolicyRevisionsAreNeverReused`, `TestResignPublishesOnlyTheRecordedContent`, `TestResignFinishesAnInterruptedPublication`. |
+| R1-2 (P1) predecessor's policy rebound to the new app | Task 8 step 7: under claims a base whose `AppID` is not `ClaimAppID` is replaced by `{V:1}` before the change, keeping the ETag and the revision floor; the GET shows the same. Test: `TestPolicyPutStartsEmptyOverAnotherIncarnationsPolicy` (failed create-time clear, then a size-only edit). |
+| R1-3 (P1) pre-claim allow outlives its expiry | Task 4: `routes.policy_expires_at` (`Route.PolicyExpiresAt`, `SetPolicy`'s fourth argument; brief updated). Task 7: converged as `SignedAt + Lifetime`; a transient pass clears a row whose recorded expiry has passed. Plan 2 Task 5 closes the allow in root on the same column. Test: `TestReconcilePolicyReadFailures` (an outage past the lifetime). |
+| R1-4 (P1) unclassified table blocks gated startup | Task 8: `app_policy_revisions` classified in `claimSQLTables` as app-scoped, writes as `claimRaw*` methods behind `withAutoAppSQL` adapters, `TestClaimSQLTableCensus` lists it. Test: `TestOpenGatedClassifiesAppPolicyRevisions`. |
+| R1-10 (P1) size-table initialization cycle | Task 2: every row of `Sizes` is a literal; the test, not the initializer, ties `standard`/`large` to today's tiers. Test: `TestSizesTableIsLiteral`. |
diff --git a/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md b/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md
index 1e4a5c77d..13aa0e2f2 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md
@@ -20,7 +20,7 @@
- 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 names the brief and plan 1's "Interfaces produced" pin: `apppolicy.{Prefix,Key,Policy,Get,PutIfMatch,Verify,VerifyKey,ErrUnverifiable,ErrExpired,Lifetime}` and `(Policy).Expired`, `egressaddr.{Parse,Allowed,FenceDeny}`, `registry.Route.EgressAllow`, `provision.PolicyReadStates`, `release.ErrRefused`, the door `PUT /api/cli/apps/{account}/{app}/policy`, its `checkEgressGrant(ctx, acct, app, old, next []string) (notChecked []string, status int, msg string)` stub and its `not_checked` reply field.
+- Consume plan 1 only by the names the brief and plan 1's "Interfaces produced" pin: `apppolicy.{Prefix,Key,Policy,Get,PutIfMatch,Verify,VerifyKey,ErrUnverifiable,ErrExpired,Lifetime}` and `(Policy).Expired`, `egressaddr.{Parse,Allowed,FenceDeny}`, `registry.Route.EgressAllow`, `registry.Route.PolicyExpiresAt` (brief, Astra plan R1-3), `provision.PolicyReadStates`, `release.ErrRefused`, the door `PUT /api/cli/apps/{account}/{app}/policy`, its `checkEgressGrant(ctx, acct, app, old, next []string) (notChecked []string, status int, msg string)` stub and its `not_checked` reply field.
- `apppolicy.Get` must return `os.ErrNotExist` only for a real absence. Over the fleet store door a refusal must surface as a different error (plan 1's transport work, spec §4.3). Task 6 tests the contract and refuses to build on it if plan 1 did not deliver it.
- User-facing strings (CLI output, door error messages, alert text, report words) are drafts until Task 17's copy review. Plain English, no em dashes. Internal error fragments and JSON keys are not copy.
- No tenant names: `shop-a.example.com`, `10.0.5.20`, `fd00:5::20`.
@@ -74,12 +74,12 @@
- `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).
+ 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 <exe> plan14-tenant`, where `exe` is the test binary's absolute path from `os.Executable()` (the precedent at `internal/host/plan14_installed_launch_root_linux_test.go:207`; `/proc/self/exe` would resolve in systemd's context, not the test's, Astra plan R1-7), and `systemctl show -p ExecStart --value carlos-egress-proof-a` names `exe`. `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`.
+ 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 and the same `<exe> plan14-tenant`, 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`
@@ -243,7 +243,7 @@ Sweep: for each populated slice, name it (`Unescape` of the part between `carlos
- 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`).
+- Consumes: `registry.Route.EgressAllow`, `registry.Route.PolicyExpiresAt` (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
@@ -252,18 +252,20 @@ Sweep: for each populated slice, name it (`Unescape` of the part between `carlos
// activator.ActivatorManaged row classifies LayoutNested under Classify with
// the layout journal; reason names the first row that fails.
func boxWhollyNested(rows []registry.Route, dataDir string) (ok bool, reason string)
-func registryEgressSource(dataDir string) instancectl.EgressSource // ignores fresh: the registry is a local file
+func registryEgressSource(dataDir string, now func() time.Time) instancectl.EgressSource // ignores fresh: the registry is a local file
func registryEgressHosts(dataDir string) func() ([]string, error)
func newEgressAsserter(src instancectl.EgressSource, hosts func() ([]string, error)) *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).
+**Expiry** (spec §3, Astra plan R1-3): a row with a non-empty `EgressAllow` whose `PolicyExpiresAt` is `0` or not after `now()` → `Closed: EgressExpired`, `Detail` `the policy expired at <RFC 3339>` (or `no expiry recorded`). The column is the agent's converge of the verified record's `SignedAt + Lifetime` (plan 1 Task 7), so it survives a restart of either service, and an agent that cannot read the store keeps the old allow only until then. The source remembers, per host, the last expiry it returned an allow under; on a registry error, a host whose remembered expiry has passed gets `Closed: EgressExpired` rather than the error (which would keep the last allow).
+
Spec §4.3 rev 8: a `Kind "sidecar"` row is outside the layout pass (`layoutleg.go:195-200`) and runs flat, so any such row makes the box not wholly nested, whatever `Classify` would say; plan 1 gives sidecar members no policy, so their own columns are empty either way.
-- [ ] **Step 1: Write the failing tests:** `TestBoxWhollyNestedTable` (all nested; one flat; one fenced by a journal file in a temp data dir; one foreign; a sidecar member row, which alone makes it false with the reason naming it; an external unflagged socket row, which does not count); `TestRegistryEgressSourceReadsTheColumn` (temp registry via the package's existing registry test helper, row `EgressAllow: "10.0.5.20 fd00:5::20"`); `TestRegistryEgressSourceClosesOnAMixedBox`; `TestRegistryEgressSourceWritesNothing` (registry file and `layout/` tree byte-identical and mtimes unchanged after a call).
-- [ ] **Step 2: Run** `go test ./internal/host -run 'TestBoxWhollyNested|TestRegistryEgressSource' -count=1`. Expected: FAIL.
-- [ ] **Step 3: Implement**, and in `RunInstanceControl` pass `newEgressAsserter(registryEgressSource(dataDir()), registryEgressHosts(dataDir()))` to `RunWithRegistry`.
+- [ ] **Step 1: Write the failing tests:** `TestBoxWhollyNestedTable` (all nested; one flat; one fenced by a journal file in a temp data dir; one foreign; a sidecar member row, which alone makes it false with the reason naming it; an external unflagged socket row, which does not count); `TestRegistryEgressSourceReadsTheColumn` (temp registry via the package's existing registry test helper, row `EgressAllow: "10.0.5.20 fd00:5::20"`, `PolicyExpiresAt` an hour ahead of the injected clock); `TestRegistryEgressSourceClosesOnAMixedBox`; `TestRegistryEgressSourceWritesNothing` (registry file and `layout/` tree byte-identical and mtimes unchanged after a call); `TestRegistryEgressSourceClosesAtTheRecordedExpiry` (expiry ahead → the allow; one second past → `Closed: EgressExpired`; `0` with an allow → `EgressExpired`; a fresh source over the same registry, as after a restart, with the clock past → `EgressExpired`; an allowed read, then the registry made unreadable and the clock moved past the expiry → `EgressExpired`, not an error); `TestPreClaimAllowClosesWhenAStoreOutageOutlastsTheRecord` (production-wired: plan 1's `provision.Reconcile` over a `DirStore` converges a record signed with a test key into a temp registry; the store wrapper then fails every policy read transiently; the clock moves past `SignedAt + apppolicy.Lifetime`; `registryEgressSource` over that registry → `Closed: EgressExpired`).
+- [ ] **Step 2: Run** `go test ./internal/host -run 'TestBoxWhollyNested|TestRegistryEgressSource|TestPreClaimAllowCloses' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Implement**, and in `RunInstanceControl` pass `newEgressAsserter(registryEgressSource(dataDir(), time.Now), registryEgressHosts(dataDir()))` to `RunWithRegistry`.
- [ ] **Step 4: Run** Step 2's command. Expected: PASS.
- [ ] **Step 5: Commit** `host: the pre-claim egress source and the wholly-nested rail (#328)`.
@@ -276,6 +278,8 @@ Spec §4.3 rev 8: a `Kind "sidecar"` row is outside the layout pass (`layoutleg.
- Modify: `internal/release/store.go` (`S3Store` and `DirStore` implement `ConditionalGetter`)
- Create: `internal/host/egress_floor.go`, `internal/host/egress_floor_test.go`
- Modify: `internal/host/egress_source.go`, `egress_source_test.go`, `claimstop_run.go` (claims branch 38-88), `claimconstruction.go:40-45` (`ClaimRuntimeResources` gains `Egress *instancectl.EgressAsserter`), `claimunit_production.go:34-...` (`bindProductionClaimUnits`: set `base.Egress` from the runtime resources before `base` is wrapped)
+- Modify: `internal/instancectl/egress.go` (`SetSource`; `assert` reads the source under `e.mu`)
+- Create: `internal/host/egress_claim_wiring.go`, `egress_claim_wiring_test.go`
**Interfaces:**
- Consumes: `rootInstallationHostApp` (`rootinstallation.go:11-45`); `apppolicy.Key`, `VerifyKey`, `ErrUnverifiable`, `ErrExpired`, `(Policy).Expired`, `release.ErrRefused` (plan 1); `hostops.ReadCanonicalAdmission` (`internal/hostops/admission.go:130-150`); `release.ParsePublicKey` on `CARLOS_RELEASE_PUBKEY` (as `readProductionRosterRow`, `claimproduction.go:84`); `rootstate.Open` and `systemGuardDir` (`systemparticipation.go:27`).
@@ -301,6 +305,15 @@ type appPolicyFloor struct { AppID string `json:"app_id"`; Revision uint64 `json
func newStoreEgressSource(hostApp func(string) (string, string, error), store release.Store,
pub ed25519.PublicKey, floors policyFloors, now func() time.Time) instancectl.EgressSource
const claimPolicyEvery = 30 * time.Second
+
+// internal/instancectl
+func (e *EgressAsserter) SetSource(src EgressSource) // under e.mu; nil-safe receiver
+
+// internal/host: the claim branch's construction order (Astra plan R1-5)
+func withClaimEgress(ctx context.Context,
+ assemble func(context.Context) (context.Context, error), // ensureProductionClaimAssembly in production
+ source func(*ClaimAssembly) instancectl.EgressSource,
+ hosts func() ([]string, error)) (context.Context, *instancectl.EgressAsserter, error)
```
The source keeps, under one mutex, per host the `(acct, app)` `hostApp` last answered, and per app the last read: ETag, the verified policy (or "absent"), the admission's `app_id`, and when it was read.
@@ -319,6 +332,8 @@ The floor lives in root's own state directory, beside the moves and seeds floors
Conditional reads: `S3Store.GetIfNoneMatch` sets `IfNoneMatch` on `GetObjectInput` and maps S3's 304 to `ErrNotModified`; `DirStore` compares its own ETag. A 304 is still a billed request, but it transfers nothing and decodes nothing.
+Construction order (Astra plan R1-5): the claim assembly does not exist until `ensureProductionClaimAssembly` builds it (`claimconstruction.go:115-136`), and `RunInstanceControl` reads `entry.Assembly` only after that (`claimstop_run.go:45-49`), but the asserter must already be in `ClaimRuntimeResources` when the assembly is opened, because `bindProductionClaimUnits` takes it from there. `withClaimEgress` therefore: (1) builds the asserter with a placeholder source answering `EgressPolicy{Closed: EgressUnreadable, Detail: "the policy source is not ready"}` (any start before step 4 is closed, never open); (2) puts it in the context's `ClaimRuntimeResources.Egress`; (3) calls `assemble`; on error returns it, with no source set; (4) reads `entry.Assembly` from the returned context and calls `SetSource(source(entry.Assembly))`; returns the context and the asserter. `RunInstanceControl` calls it in place of its bare `ensureProductionClaimAssembly` call, so the source is set before `NewProductionRootClaimStop`, `WithProductionClaimLaunch` and `activateClaimRoot`, that is before any claim launch can activate.
+
- [ ] **Step 1: Write the failing tests** (memory store, `hostApp` stub, a fresh `ed25519.GenerateKey` as the release key, records written with `apppolicy.PutIfMatch`, a `policyFloors` fake backed by a `t.TempDir()` map that persists across sources, an injected clock):
`conditional_get_test.go`:
@@ -339,8 +354,12 @@ Conditional reads: `S3Store.GetIfNoneMatch` sets `IfNoneMatch` on `GetObjectInpu
- `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/release -run 'TestGetIfNoneMatch|TestS3StoreIsAConditionalGetter' -count=1 && go test ./internal/host -run 'TestStoreEgress' -count=1`. Expected: FAIL.
-- [ ] **Step 3: Implement.** In `RunInstanceControl`'s claims branch: parse `CARLOS_RELEASE_PUBKEY` (a parse error is fatal there, as the roster read's is), open the floor directory, build `newEgressAsserter(newStoreEgressSource(rootInstallationHostApp(entry.Assembly.Guard, entry.Assembly.Claims), entry.Assembly.Claims, pub, floors, time.Now), registryEgressHosts(dataDir()))`, put it in `ClaimRuntimeResources.Egress` before `ensureProductionClaimAssembly`, and pass it to `RunWithClaim`. The registry is used there only to name slices; desired lists come from the store. Comment on `newStoreEgressSource`: why it verifies (the agent holds the box role and can write the key, spec §3 rev 8), why the floor is in root's state, why step 5 precedes step 6.
+
+`egress_claim_wiring_test.go`:
+ - `TestClaimEgressSourceIsBoundAfterTheAssembly`: an `assemble` stub that asserts the context's `ClaimRuntimeResources.Egress` is non-nil and that an `Assert` through it now resets the slice with `State: EgressUnreadable` (the placeholder), then returns a context whose `entry.Assembly` is a non-nil fixture; `source` receives that same non-nil assembly; afterwards `Assert` answers from the returned source. With the plan's earlier order (`source(entry.Assembly)` before `assemble`), `source` would receive nil.
+ - `TestClaimEgressAssemblyFailureSetsNoSource`: `assemble` fails → the error is returned and `source` is never called.
+- [ ] **Step 2: Run** `go test ./internal/release -run 'TestGetIfNoneMatch|TestS3StoreIsAConditionalGetter' -count=1 && go test ./internal/host -run 'TestStoreEgress|TestClaimEgress' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Implement.** In `RunInstanceControl`'s claims branch: parse `CARLOS_RELEASE_PUBKEY` (a parse error is fatal there, as the roster read's is) and open the floor directory **before** the assembly; then, after `withClaimRuntimeResources`, replace the `ensureProductionClaimAssembly` call with `ctx, egress, err = withClaimEgress(ctx, ensureProductionClaimAssembly, func(a *ClaimAssembly) instancectl.EgressSource { return newStoreEgressSource(rootInstallationHostApp(a.Guard, a.Claims), a.Claims, pub, floors, time.Now) }, registryEgressHosts(dataDir()))`, and pass `egress` to `RunWithClaim`. The registry is used there only to name slices; desired lists come from the store. Comment on `newStoreEgressSource`: why it verifies (the agent holds the box role and can write the key, spec §3 rev 8), why the floor is in root's state, why step 5 precedes step 6.
- [ ] **Step 4: Run** Step 2's command and `go test ./internal/host -run 'TestClaimUnit|TestBindProductionClaimUnits' -count=1`. Expected: PASS. Cross-compile: `GOOS=darwin go build ./internal/host ./internal/release && GOOS=windows go build ./internal/host ./internal/release`.
- [ ] **Step 5: Commit** `host, release: under claims root verifies the signed egress policy, holds a revision floor and reads each app at most every 30 s (#328)`.
@@ -533,12 +552,17 @@ func egressCell(e *apiEgress) string // root package
- Modify: `internal/host/plan14_installed_launch_root_linux_test.go` (add an `egress:` mode to `plan14InstalledClaimLaunch` 149-...)
**Interfaces:**
-- Consumes: everything above (the asserter built with `newEgressAsserter(src, hosts)`, two arguments). Installs templates from `unitsFS` the way `TestRootFilesSeedIsolatedTenant` does (`claimfilesseed_isolated_rootrunner_linux_test.go:100-175`, `/opt/carlos/carlos` = the test binary, `BindsTo=`/`Requires=`/`After=` dropped, cleanup of `/run/systemd/system.control/<slice>.d`), and the dummy-link fixture from Task 1 (factor it into a helper in Task 1's file: `egressDummyLink(t) (addrs map[string]string)`).
-
-- [ ] **Step 1: Write `TestRootEgressStartPathsPreClaim`**: a real `instancectl.Server` with `Egress: newEgressAsserter(registryEgressSource(dir), registryEgressHosts(dir))`, 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 `""`.
+- Consumes: everything above (the asserter built with `newEgressAsserter(src, hosts)`, two arguments). Installs templates from `unitsFS` the way `TestRootFilesSeedIsolatedTenant` does (`claimfilesseed_isolated_rootrunner_linux_test.go:100-175`, `/opt/carlos/carlos` = the test binary, `BindsTo=`/`Requires=`/`After=` dropped, cleanup of `/run/systemd/system.control/<slice>.d`), and a target fixture in this task's file, `egressTargetNetns(t) (addrs map[string]string)`. Not Task 1's dummy link: that puts `10.0.5.20` on the box itself, and the production asserter (Task 2's own-address check against `LocalAddresses`) must then refuse it (Astra plan R1-6). The fixture, all removed in `t.Cleanup` (`ip netns del` removes the pair; the contract's "every interface" rule):
+ - network namespace `carlos-egr`; veth pair `carlos-egr0` (host) / `carlos-egr1` (in the namespace), both up;
+ - host side: `10.0.5.1/32` and `fd00:5::1/128 nodad` on `carlos-egr0`, routes `10.0.5.20/32`, `10.0.5.21/32` (`src 10.0.5.1`) and `fd00:5::20/128` (`src fd00:5::1`) via `dev carlos-egr0`;
+ - namespace side: `10.0.5.20/32`, `10.0.5.21/32` and `fd00:5::20/128 nodad` on `carlos-egr1`, routes back to `10.0.5.1/32` and `fd00:5::1/128` via `dev carlos-egr1`, `lo` up;
+ - a TCP listener on each target address opened inside the namespace (lock the OS thread, `unix.Setns` into `/run/netns/carlos-egr`, `net.Listen`, `Setns` back, unlock: the listening socket keeps its namespace), served by the test process;
+ - before returning, it asserts that `instancectl.LocalAddresses()` holds none of `10.0.5.20`, `10.0.5.21`, `fd00:5::20`, so the asserter's production validation, own-address check included, stays on in every witness below and in Task 14.
+
+- [ ] **Step 1: Write `TestRootEgressStartPathsPreClaim`**: a real `instancectl.Server` with `Egress: newEgressAsserter(registryEgressSource(dir, time.Now), registryEgressHosts(dir))`, served on a scratch unix socket with `PeerOK` accepting the test, `egressTargetNetns(t)`, a temp registry whose nested row for `shop-a.example.com` carries `EgressAllow: "10.0.5.20"` and `PolicyExpiresAt` an hour ahead, 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 **regrant**: set the column back to `10.0.5.20` and `POST /egress`; dial answers 204 within 2 s and the slice reads back `10.0.5.20/32` (so the next revoke has something to write, Astra plan R1-11). Then install a `Systemctl` wrapper that fails the next `set-property` on that slice once, clear the column and `POST /egress`: the POST answers 502, the wrapper recorded exactly one failed `set-property`, `GET /egress` reports `failing` with `Allowed: ["10.0.5.20"]`. Then the next `Sweep`: the slice reads back empty, dial answers 403 within 2 s, and the report's `State` is `""`.
- [ ] **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 signed with the harness's release key at `apppolicy.Key` with the app's `app_id` (revision 2); the claim launch through `ClaimUnits.StartReserved` (and so `claimEffectiveUnit`) succeeds, the tenant reaches `10.0.5.20`, and `/opt/carlos/system/guard/app-policy/<sqid>.<app>.json` (real `rootstate`) says revision 2. Then: restart the control service (a new source and asserter over the same floor directory), put a revision 1 record granting `10.0.5.20` back at the key, and `POST /egress`: the slice is reset and `GET /egress` says `replayed`. Then a revision 3 record with another `app_id`: the next sweep closes it with `other_incarnation`.
+- [ ] **Step 4: Add `TestPlan14EgressClaimLaunchRootRunner`** (`plan14InstalledClaimLaunch(t, "egress:")`, with `egressTargetNetns(t)`): the harness's store fixture gets a policy signed with the harness's release key at `apppolicy.Key` with the app's `app_id` (revision 2); the claim launch through `ClaimUnits.StartReserved` (and so `claimEffectiveUnit`) succeeds, the tenant reaches `10.0.5.20`, and `/opt/carlos/system/guard/app-policy/<sqid>.<app>.json` (real `rootstate`) says revision 2. Then: restart the control service (a new source and asserter over the same floor directory), put a revision 1 record granting `10.0.5.20` back at the key, and `POST /egress`: the slice is reset and `GET /egress` says `replayed`. Then a revision 3 record with another `app_id`: the next sweep closes it with `other_incarnation`.
- [ ] **Step 5: Cross-compile** `GOOS=linux GOARCH=arm64 go test -c -tags rootrunner -o $TMPDIR/egress-root.test ./internal/host && rm $TMPDIR/egress-root.test`. Expected: builds.
- [ ] **Step 6: Run on the root runner**, sandbox off: `--run '^(TestRootEgressStartPathsPreClaim|TestRootEgressRevokeWhileRunning|TestRootEgressMixedBoxWritesNothing|TestPlan14EgressClaimLaunchRootRunner)$'`. Expected: PASS; run id in the report.
- [ ] **Step 7: Commit** `host: production-wired egress witnesses for every start path and a live revoke (#328)`.
@@ -550,7 +574,7 @@ func egressCell(e *apiEgress) string // root package
**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 1: Write `TestRootEgressIsolation`**: the Task 13 server and `egressTargetNetns(t)` with two nested rows on one box, `shop-a.example.com` (app A, `EgressAllow: "10.0.5.20"`, `PolicyExpiresAt` an hour ahead) 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)`.
@@ -576,7 +600,7 @@ func egressCell(e *apiEgress) string // root package
- Modify: `internal/provision/plan14_root_reads_test.go` (`TestPlan14RootReadCensus`, after the last overlay load at ~1100: load this one last, after `plan14-root-reads-ci-speed-activator.tsv` or after plan 1's overlay if plan 1 added one, checking the previous seal first)
- [ ] **Step 1: Run** `go test ./internal/provision -run '^TestPlan14RootReadCensus$' -count=1`. Expected: FAIL listing the new units.
-- [ ] **Step 2: Write the overlay and the proposal** in the shape of `census-proposal-moves-offline-fixes.md`, every row `rev3=PROPOSED-instance-egress`. Units and executing identities to classify: root (`carlos-instance-control`): `EgressAsserter.Assert`, `Sweep`, `Loop`, `populatedHostSlices` (cgroupfs read), `Server.handleEgress*`, the five start-path changes and `ClaimUnits.start`; `registryEgressSource`, `registryEgressHosts`, `boxWhollyNested` (root reading the agent-written registry and `layout.JournalPresent`, pre-claim only); `newStoreEgressSource` (root reading the store and `CARLOS_RELEASE_PUBKEY`, verifying the signed record; no agent-written input) and its floor (`appPolicyFloorDir`, a root write under `/opt/carlos/system/guard/app-policy/`, the class of the moves and seeds floors); `release.GetIfNoneMatch` (a conditional read, same class as `Get`). The agent (user carlos): `egressNudgeLoop`, `boxAddrs` (procfs), `egressStatusProbe`. Propose dispositions against main's siblings (`instanceRegistryHostApp`, `allowResolvers`, `rootInstallationHostApp`) and state the trust argument from spec §4.3 for each root registry read. Re-pin the envelopes of `internal/instancectl`, `internal/host`, `internal/edgesfile`, `internal/status`, `internal/release`, `internal/apppolicy`; record the new seal.
+- [ ] **Step 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` (now also reading `policy_expires_at`), `registryEgressHosts`, `boxWhollyNested` (root reading the agent-written registry and `layout.JournalPresent`, pre-claim only); `withClaimEgress` (startup wiring, no read of its own); `newStoreEgressSource` (root reading the store and `CARLOS_RELEASE_PUBKEY`, verifying the signed record; no agent-written input) and its floor (`appPolicyFloorDir`, a root write under `/opt/carlos/system/guard/app-policy/`, the class of the moves and seeds floors); `release.GetIfNoneMatch` (a conditional read, same class as `Get`). The agent (user carlos): `egressNudgeLoop`, `boxAddrs` (procfs), `egressStatusProbe`. Propose dispositions against main's siblings (`instanceRegistryHostApp`, `allowResolvers`, `rootInstallationHostApp`) and state the trust argument from spec §4.3 for each root registry read. Re-pin the envelopes of `internal/instancectl`, `internal/host`, `internal/edgesfile`, `internal/status`, `internal/release`, `internal/apppolicy`; record the new seal.
- [ ] **Step 3: Run** Step 1's command. Expected: PASS.
- [ ] **Step 4: Commit** `census: overlay for the egress allow's root reads (proposed, #280) (#328)`, then post the proposal to the #280 thread and ask for the ruling. Merge waits for it.
@@ -599,3 +623,14 @@ func egressCell(e *apiEgress) string // root package
- Spec coverage: §4.1 (Task 2, root side; door side is plan 1), §4.2 (Tasks 8, 9, 10, root's own-address drop in Task 2), §4.3 sources (Tasks 5, 6), write and when (Tasks 2, 3, 4, 7), kernel proof (Task 1), §4.4 (Tasks 11, 12), §3 `app_id` (Task 6), §3 rev 8 signed record verified by root, revision floor in root's state surviving a restart, `policy replayed` / `policy expired` / `policy unverifiable`, per-app 30 s conditional read (Task 6, witnessed in Task 13), §4.3 rev 8 sidecar members (Task 5), §7 byte-identical (Task 15), §11 egress items (Tasks 1, 2, 9, 10, 13, 14, 15; the fleet-door refusal in Task 6). Sizes are plan 3.
- Not this plan's: the policy object, door, CLI `apps set`, purge sweep, store-door read grant and the agent's column convergence (plan 1). Task 6 Step 1 refuses to proceed if the refusal-is-not-absence contract is missing.
+- Re-run after Astra plan R1: spec coverage unchanged; §3's lifetime now also holds before claims (Task 5 reads plan 1's `policy_expires_at`). Names checked: `registryEgressSource(dataDir, now)` in Tasks 5 and 13, `withClaimEgress` and `SetSource` in Task 6 only, `egressTargetNetns` in Tasks 13 and 14; no `egressDummyLink` and no `/proc/self/exe` remain. No new copy (the `Detail` texts are internal).
+
+## Review dispositions (Astra plan R1)
+
+| Finding | What changed |
+|---|---|
+| R1-3 (P1) pre-claim allow outlives its expiry | Task 5: `registryEgressSource(dataDir, now)` closes an allow whose `PolicyExpiresAt` (plan 1's new column) is `0` or past as `EgressExpired`, and on a registry error closes a host whose remembered expiry has passed. Tests: `TestRegistryEgressSourceClosesAtTheRecordedExpiry`, `TestPreClaimAllowClosesWhenAStoreOutageOutlastsTheRecord` (through plan 1's real `Reconcile`). |
+| R1-5 (P1) assembly dereferenced before construction | Task 6: `withClaimEgress` puts an asserter with a closed placeholder source in `ClaimRuntimeResources`, runs `ensureProductionClaimAssembly`, then `SetSource` from the built assembly, all before `NewProductionRootClaimStop`, `WithProductionClaimLaunch` and `activateClaimRoot`. Tests: `TestClaimEgressSourceIsBoundAfterTheAssembly`, `TestClaimEgressAssemblyFailureSetsNoSource`. |
+| R1-6 (P1) acceptance target is the box's own address | Task 13: `egressTargetNetns(t)` puts the targets in a separate network namespace behind a veth pair and asserts `LocalAddresses()` holds none of them, so production validation stays on; Tasks 13 Step 4 and 14 use it too. Task 1 keeps its dummy link (it drives `systemctl` directly, not the asserter). |
+| R1-7 (P1) kernel witness launches the wrong executable | Task 1: `systemd-run` gets the test binary's absolute path from `os.Executable()` (precedent `plan14_installed_launch_root_linux_test.go:207`), and the step checks `ExecStart` names it. |
+| R1-11 (P2) failed-revoke witness never writes | Task 13 Step 2: regrant and prove reachability first, then inject the failing write; assert the POST's 502, the one failed `set-property`, `failing`, and that the next `Sweep` closes the address. |
diff --git a/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md b/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md
index abafec07f..8ade8a1b5 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md
@@ -37,7 +37,7 @@
## 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`.
+- `registry.Route.Size string` (`routes.size`, `''` = the build's tier) and `(*Registry).SetPolicy(host, egressAllow, size string, expiresAt int64) error`.
- The policy door `PUT /api/cli/apps/{account}/{app}/policy` (`handleAPIPolicyPut`, `internal/console/apipolicy.go`) and its reply `{policy, etag, not_checked, stale_overrides}`, to which Task 9 adds `committed`; the CLI's `apps set --size` and `instances set --size` (`apps_policy.go`, `appsSetRun`, `instancesSetRun`).
- `provision.ReconcileConfig.PolicyChanged func(host string)`: called after each converge of a row's policy columns (Task 7 sets it to `NoteSize`).
@@ -117,11 +117,11 @@ Expected: FAIL (small and xlarge refused by `ValidParentLimit`, strict validator
- [ ] **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.
+Plan 1's `Sizes` rows are literals (its Task 2, pinned by `TestSizesTableIsLiteral`); keep them so, and check them against the Global Constraints table. Only then derive the other way: `TierDefault`/`TierChildren` from the table (an unexported `mustSize(name) Size` that panics at init on a missing row). Go orders package initialization by dependency, so `Sizes` is initialized first; a `Sizes` row written as `TierDefault` or `SharesOf(...)` would make this an initialization cycle and the package would not compile (Astra plan R1-10). `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'`
+Run: `go vet ./internal/cgroup/ && go test ./internal/cgroup/ -run 'Size|Tier|Shares|ValidParent|MaxLimit|Strict|PrepareGen|StartChild'` (`go vet` fails on an initialization cycle; `TestSizesTableIsLiteral` from plan 1 runs under `Size`)
Expected: PASS.
- [ ] **Step 5: Commit** (`cgroup: the size table drives tiers, shares and every limit validator`)
@@ -263,7 +263,7 @@ func TestSwapSuccessorStartsAtTheLargerSizeOnlyWithAnOperatorSize(t *testing.T)
- `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: **under claims there is no live change** (spec §5.4 rev 8), growth included, and the new size lands at the host's next start (shown as pending). A live verb for claim mode is follow-up work, not this plan's.
- - `func (c *Client) ReadSize(ctx context.Context, host string, gen uint64) (SizeReadBack, error)`
+ - `func (c *Client) ReadSize(ctx context.Context, host string, gen uint64) (SizeReadBack, error)`: 400, 409 and 503 wrap `ErrSizeRefused`, as `Size`'s do (under claims the activator's first call is this read, Task 6).
- `func SliceControlGroup(host string) string` in `names.go`: `path.Join(parentSlices, SliceName(host))`, for the agent's read in Task 7.
`handleSize`, in order:
@@ -297,7 +297,7 @@ func TestSizeRefusesBadInputs(t *testing.T) // size=huge, mem
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
+func TestClientSizeMapsRefusalsToErrSizeRefused(t *testing.T) // Size and ReadSize: 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.
@@ -323,7 +323,8 @@ func TestClientSizeMapsRefusalsToErrSizeRefused(t *testing.T) // 400, 409 and 5
- `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.
+ - `func genUnitOf(rt registry.Route, g Gen) uint64` (`size.go`): `g.N` when the entry runs in a real generation unit (`!g.Worker && linksCanonical(rt, g)`, `canonlink.go:27`: the generation bound a socket of its own, so `startRole` started it through `GenControl`), else `0`. A `canonicalGen` handle (`generations.go:168`) is numbered on the ordinary run path even with generations off (`activator.go:1870`), but there is no unit by that number: root's `/size` with that `gen` would check a unit that does not exist and answer 409 (Astra plan R1-8). `0` is root's run-template path: the slice alone.
+ - `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. `Gen` is `genUnitOf(e.route, e.gen)`.
- 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`:
@@ -332,38 +333,57 @@ func TestClientSizeMapsRefusalsToErrSizeRefused(t *testing.T) // 400, 409 and 5
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
+mark resizing + resizeDone; snapshot rt := e.route, g := e.gen, unsettled := e.ops.sizeUnsettled, dirty
+unit := genUnitOf(rt, g) // 0 = the run template: the slice alone (Astra plan R1-8)
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
+ if !unsettled && !dirty && fresh.Size == e.sizeSeen: done // no operator change: no call, ever
+ rb, err := ReadSize(rt, unit) // what the slice and unit hold now
+ ErrSizeRefused or errSizeNotLive: as the refusal arm below; any other error: end the pass, unsettled unchanged
+ observed := Limit{rb.SliceMemoryMax}; if unit > 0 { observed = MaxLimit(observed, Limit{rb.UnitMemoryMax}) }
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)
+ mem := MaxLimit(want.Limit, observed) // never below anything observed (R1-3, Astra plan R1-9)
+ rb, err = ResizeLive(fresh, unit, want, mem.MemoryMax) // also brings a lagging unit up to the slice
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
+ err == nil: settled := unit == 0 || rb.UnitMemoryMax == rb.SliceMemoryMax
+ if settled { clear e.ops.sizeUnsettled; if e.gen.N == g.N { e.gen.Tier = Limit{rb.UnitMemoryMax, or rb.SliceMemoryMax when unit == 0} } } // R2-1: from read-back, never from the request
+ else { e.ops.sizeUnsettled = true }
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)
+ errors.Is(err, ErrSizeRefused) or errSizeNotLive: clear dirty and record sizeSeen (nothing written; the change waits for the next start); sizeUnsettled UNCHANGED: a refusal proves nothing about the limits already on the host
+ any other error: e.ops.sizeUnsettled = true (slice may be written; no swap starts until a write 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. Under host claims the call is answered 503 → `ErrSizeRefused`: nothing is written, `sizeSeen` records the column, and the size takes effect at the next start (no live change under claims, spec §5.4 rev 8). An agent-cleared column (an expired or unverifiable policy, plan 1) is an ordinary change to `''`: `want` is the build's tier and `MaxLimit` keeps memory where it is, so only CPU and IO move live.
+`unsettled` (Astra plan R1-9) is cleared only by a successful write whose read-back shows the slice and the serving unit at one `MemoryMax`, never by a read alone and never by a refusal. While it is set, the next pass retries even with no operator change (the `done` check above), sizing the write from the larger of the two observed figures, so a policy reversed after a partial growth (slice grown to xlarge, the unit write failed, the size set back to standard) brings the unit up to the slice rather than asking root for a shrink it refuses; and swap admission keeps refusing (`errSwapBusy`) until then, so a swap never sizes its parent from a captured tier below what the slice already holds.
-- [ ] **Step 1: Write the failing tests** (swap fixture; the fake records `size:<name>/<mem>/<gen>` and `readsize:<gen>` events and returns scripted read-backs)
+The activator never sends a change while a parent is raised: a change that arrives mid-swap waits, because `retrySizeLocked` skips until the set is empty and the restore has succeeded. A change with no operator involvement (a release that changes the build's tier) sends nothing live: `sizeSeen` only moves when the column does. Under host claims the first call (`ReadSize`) is answered 503 → `ErrSizeRefused`: nothing is written, `sizeSeen` records the column, and the size takes effect at the next start (no live change under claims, spec §5.4 rev 8). An agent-cleared column (an expired or unverifiable policy, plan 1) is an ordinary change to `''`: `want` is the build's tier and `MaxLimit` keeps memory where it is, so only CPU and IO move live.
+
+- [ ] **Step 1: Write the failing tests** (swap fixture; the fake records `size:<name>/<mem>/<gen>` and `readsize:<gen>` events and returns scripted read-backs; "gen 1" is a real generation unit, generations on and its own socket, unless a test says otherwise; every write is preceded by its `readsize`)
```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.
+// events "readsize:1" (slice and unit 536870912) then "size:large/1073741824/1"; fake read-back slice and unit 1073741824;
+// afterwards Current(host).Tier == TierChildren, SizeState.Unsettled false.
+func TestLiveGrowthWithGenerationsOffUsesTheRunTemplate(t *testing.T)
+// Astra plan R1-8. Activator with generations off: the awake entry's handle is canonicalGen (N > 0, the
+// route's own socket). The fake answers any nonzero gen with ErrSizeRefused (root's 409 for a unit that
+// does not exist). Size "" -> "large": events "readsize:0", "size:large/1073741824/0"; read-back slice
+// 1073741824; SizeState.Gen == 0, Unsettled false; Want large and no pending mark.
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.
+// next sweep with no operator change: readsize and size are sent again; a read-back with slice == unit
+// -> Unsettled false, Gen.Tier from the read-back; Swap proceeds.
+func TestPolicyReversalAfterAPartialGrowthStaysUnsettled(t *testing.T)
+// Astra plan R1-9. Gen 1 at 512 MiB; Size "" -> "xlarge"; ResizeLive fails 502-style (the slice was
+// written, the unit was not) -> Unsettled. Size -> "standard", NoteSize. Next sweep: readsize:1 answers
+// slice 2147483648, unit 536870912; the write is "size:standard/2147483648/1" (the larger observed figure,
+// never 536870912); its read-back slice and unit 2147483648 -> Unsettled false, Current.Tier 2 GiB; a Swap
+// then raises from 2 GiB and restores to 2 GiB (never below the slice). Variant: the fake refuses that
+// write with ErrSizeRefused -> Unsettled stays true and Swap -> errSwapBusy; a refusal never settles.
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.
@@ -374,14 +394,14 @@ func TestNoLiveCallWithoutAnOperatorChange(t *testing.T)
func TestNoLiveCallForAsleepWorkerOrFlat(t *testing.T)
// entry asleep, a worker, or ResizeLive returning errSizeNotLive: no unsettled mark, no retry storm (one call at most).
func TestNoLiveChangeUnderClaims(t *testing.T)
-// ResizeLive returning an error wrapping ErrSizeRefused (root's 503 under claims), Size "" -> "xlarge":
-// one call, no unsettled mark, Current.Tier unchanged, SizeState.Want xlarge (pending); no second call on
+// ReadSize and ResizeLive returning an error wrapping ErrSizeRefused (root's 503 under claims), Size "" -> "xlarge":
+// one readsize call and no size call, no unsettled mark, Current.Tier unchanged, SizeState.Want xlarge (pending); no second call on
// the next sweep; the next spawn (a wake after sleep) carries 2 GiB.
func TestJoinOpsWaitsForAResizeInFlight(t *testing.T)
// ResizeLive gated; a stop joins and returns only after the gate opens.
```
-- [ ] **Step 2: Run to see them fail**: `go test ./internal/activator/ -run 'LiveGrowth|LiveChangeWaits|SwapRefusedWhileResizing|MemoryShrink|SizeBack|NoLiveCall|NoLiveChangeUnderClaims|JoinOpsWaitsForAResize'`. Expected: FAIL.
+- [ ] **Step 2: Run to see them fail**: `go test ./internal/activator/ -run 'LiveGrowth|LiveChangeWaits|SwapRefusedWhileResizing|PolicyReversalAfterAPartialGrowth|MemoryShrink|SizeBack|NoLiveCall|NoLiveChangeUnderClaims|JoinOpsWaitsForAResize'`. Expected: FAIL.
- [ ] **Step 3: Implement** as above. `ExecBackend.ResizeLive`/`ReadSize` mirror `ParentLimit`'s layout switch (`containment.go:365-386`).
@@ -614,3 +634,15 @@ The cell words `swap`, `-` and `?` are reviewed with string 4. Root's `/size` er
- [ ] **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`)
+
+## Self-review (after Astra plan R1)
+
+Spec coverage is unchanged (§5.1 table, §5.4 growth, never-shrink and the swap rule). Names checked: `genUnitOf` is defined in Task 6 and used for `ResizeLive`, `ReadSize` and `SizeState.Gen`, which Task 7's `readSizeInForce` reads; `Client.ReadSize` maps refusals as Task 6 expects; `SetPolicy`'s fourth argument matches plan 1 and the brief (this plan never calls it). No new copy.
+
+## Review dispositions (Astra plan R1)
+
+| Finding | What changed |
+|---|---|
+| R1-8 (P1) logical generation numbers block live growth | Task 6: `genUnitOf` sends `gen=0` (root's run-template path, the slice alone) unless the entry runs in a real generation unit (`linksCanonical`), and the slice limit is read before every write. Test: `TestLiveGrowthWithGenerationsOffUsesTheRunTemplate`. |
+| R1-9 (P1) partial growth then a reversal permits a lowering swap | Task 6: every write is sized from the larger of the observed slice and unit limits; `sizeUnsettled` clears only on a write whose read-back shows slice and unit equal, never on a read or a refusal; swap admission keeps refusing until then. Task 5: `ReadSize` maps refusals to `ErrSizeRefused`. Test: `TestPolicyReversalAfterAPartialGrowthStaysUnsettled`. |
+| R1-10 (P1) size-table initialization cycle | Task 1: plan 1's literal `Sizes` rows stay literal; only the tiers and `SharesOf` are derived, from the table; `go vet` and plan 1's `TestSizesTableIsLiteral` run in Step 4. |
diff --git a/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md b/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md
index 2c81fd662..6aab10ff6 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md
@@ -50,7 +50,8 @@ Plan 1 produces these; plans 2 and 3 use them by these exact names.
- **Registry columns** (plan 1 migrates and converges; plans 2 and 3 read):
- `routes.egress_allow TEXT NOT NULL DEFAULT ''`: the normalized list, space-separated.
- `routes.size TEXT NOT NULL DEFAULT ''`: the effective size name, `''` meaning the build's tier.
- - `func (r *Registry) SetPolicy(host, egressAllow, size string) error`, and both fields on `registry.Route` as `EgressAllow string` and `Size string`.
+ - `routes.policy_expires_at INTEGER NOT NULL DEFAULT 0` (Astra plan R1-3): the verified record's `SignedAt + Lifetime`, unix seconds; `0` = none recorded. Before claims root treats a non-empty `egress_allow` whose expiry is `0` or past as no allow, so the record's lifetime holds on the box through a store outage and a restart.
+ - `func (r *Registry) SetPolicy(host, egressAllow, size string, expiresAt int64) error`, and the three fields on `registry.Route` as `EgressAllow string`, `Size string` and `PolicyExpiresAt int64`.
- **Door:** `PUT /api/cli/apps/{account}/{app}/policy` with body `{"egress_allow": [...] | null, "default_size": "..." | null, "size": {"host": "...", "value": "..."} | null}`, `If-Match` the policy ETag (or `If-None-Match: *`); reply `{"policy": {...}, "etag": "...", "not_checked": [...], "stale_overrides": [...]}`, where plan 2 fills `not_checked` (through plan 1's `checkEgressGrant` stub) and adds `egress_conflicts` to the GET reply, and plan 3 adds `committed`. `GET` on the same path answers the same shape. Gate: `apiSystemRoller`, empty `svcrole.Set{}`. Audit before write; refuse if the audit write fails.
- **Agent hooks** (plan 1's `internal/provision`): `ReconcileConfig.PolicyChanged func(host string)` (plan 3 sets it to the activator's `NoteSize`); `PolicyReadStates() map[string]string` (plan 2 reports it).
- **Sidecar members** (`Kind "sidecar"` rows) get no policy, and any such row makes a box not wholly nested (spec §4.3 rev 8): plan 1 leaves their columns empty, plan 2 writes no allow on that box before claims.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R1.md b/docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R1.md
new file mode 100644
index 000000000..addfeb281
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R1.md
@@ -0,0 +1,23 @@
+1. **P1 — Plan 1, Tasks 8–9: a failed revision-counter write can resurrect a revoked grant.** [Foundation:570](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:570) permits the policy PUT to succeed while persisting its revision fails. After revoke revision 2, the console’s counter can remain 1. A bucket writer can restore revision 1, which the re-sign pass accepts at foundation:665. A subsequent size-only edit signs the restored allow at revision 2, accepted even by roots that observed the original revoke. **Smallest fix:** durably record recoverable signed content and its revision before publication; recover interrupted publication without reusing a revision for different content. Test failure between the bucket write and counter update.
+
+2. **P1 — Plan 1, Task 8: an old incarnation’s policy can be rebound to the new app.** [Foundation:570](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:570) retains the stored fields, then replaces `AppID` with the current incarnation. Task 10 explicitly tolerates failed cleanup under claims at foundation:708. Consequently, a size-only edit can sign the predecessor’s egress grant for the new app, defeating spec §3’s incarnation protection. `internal/console/claim_app_identity.go:58` supplies the current identity but does not validate the policy. **Smallest fix:** treat a mismatched stored `AppID` as empty policy before applying changes, retaining its ETag and revision floor. Test failed cleanup followed by a size-only edit.
+
+3. **P1 — Plan 1, Task 7: pre-claim grants can outlive their signed expiry.** [Foundation:496](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:496) preserves registry columns indefinitely on transient store errors. Those columns contain no expiry information; plan 2’s pre-claim source reads them unchanged. A grant therefore remains open beyond its 72-hour lifetime during a sustained outage, contrary to spec §3. **Smallest fix:** retain the verified expiry durably and enforce it during read failures and after restart; close the allow when validity cannot be established.
+
+4. **P1 — Plan 1, Task 8: the new table prevents claim-enabled console startup.** [Foundation:562](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:562) introduces `app_policy_revisions` without adding it to `claimSQLTables`. `internal/console/store_gate.go:307` rejects every unclassified table, and `OpenGated` returns that failure at store_gate.go:1854. **Smallest fix:** classify the table as app-scoped, implement its writes through the permitted SQL path, and add an `OpenGated` migration test.
+
+5. **P1 — Plan 2, Task 6: claim assembly is dereferenced before construction.** [Egress:343](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md:343) constructs the source from `entry.Assembly.Guard` and `.Claims` before calling `ensureProductionClaimAssembly`. That function creates and assigns the assembly at `internal/host/claimconstruction.go:134`; current startup retrieves it afterwards at claimstop_run.go:49. **Smallest fix:** supply an asserter placeholder in runtime resources, construct the assembly, then initialize its source before activating claim launches.
+
+6. **P1 — Plan 2, Task 13: production validation rejects the acceptance fixture’s target.** [Egress:536](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md:536) reuses Task 1’s dummy link, which assigns `10.0.5.20` to the box at egress:72. The production asserter must reject that address under its own-address check at egress:166–177, but the witness expects it to be allowed at egress:538. **Smallest fix:** place the target listener in a separate network namespace behind a veth, keeping the target address absent from the host’s local-address inventory and retaining production validation.
+
+7. **P1 — Plan 2, Task 1: the kernel witness launches the wrong executable.** [Egress:77](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md:77) passes `/proc/self/exe` to `systemd-run`. Systemd resolves `self` in its execution context, not the Go test process containing `plan14-tenant`. The existing harness obtains the test executable with `os.Executable()` at `internal/host/plan14_installed_launch_root_linux_test.go:207`. **Smallest fix:** resolve and pass the test binary’s absolute path.
+
+8. **P1 — Plan 3, Task 6: logical generation numbers prevent live growth on the ordinary run path.** [Sizes:338](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md:338) and sizes:343 pass `g.N` to root. However, `internal/activator/activator.go:1870` assigns a numbered `canonicalGen` even with generations disabled; generations.go:168 documents this. Root checks a nonexistent generation unit, returns 409, and the plan records the change as handled. **Smallest fix:** distinguish actual generation units from logical handles; use `gen=0` for the run-template path and read its slice limit. Test growth through the activator with generations disabled.
+
+9. **P1 — Plan 3, Task 6: partial growth can permit a memory-lowering swap.** [Sizes:338](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md:338) clears `unsettled` after any successful read. If the slice grows from standard to xlarge, the unit write fails, and desired size returns to standard, the read reports slice=xlarge/unit=standard. The retry is refused as a shrink, but sizes:347 treats that refusal as settled. A swap then calculates its parent from the smaller captured tier (`internal/activator/swap.go:498–517`), potentially lowering memory beneath running sidecars or commands. **Smallest fix:** retain `unsettled` until slice and unit limits are reconciled without lowering observed slice memory. Test a policy reversal after partial growth.
+
+10. **P1 — Plans 1 and 3, size-table tasks: the prescribed initializers form a cycle.** [Foundation:200](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:200) requires `Sizes` entries initialized from `TierDefault`, `TierChildren`, and `SharesOf`. [Sizes:120](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md:120) redirects those variables and functions back through `Sizes`. Following both creates a Go initialization cycle. **Smallest fix:** explicitly replace the symbolic table initializers with literal limits and shares before redirecting the tier helpers.
+
+11. **P2 — Plan 2, Task 13: the failed-revoke witness never attempts its injected write.** [Egress:539](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md:539) successfully clears the allow, then injects a write failure and clears again. Desired and held lists are already empty, so the comparison at egress:177 skips the write. **Smallest fix:** regrant and verify reachability before injecting the failing revoke; assert that the write failed and a subsequent sweep closed the address.
+
+REJECT
\ No newline at end of file