| 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`. | |