carlos / platform Public

plans 1-3 revised for spec rev 9 (deferral, writer lease, change index, R2 fixes); spec §3a costs stated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 479fafc1a7d4f8930d7311d6cd274f6fefa4b26e parent 4064231
5 files changed, +550 −273
  • docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md +320 −100
  • docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md +123 −138
  • docs/superpowers/plans/2026-10-21-instance-policy-3-sizes.md +50 −18
  • docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md +40 −12
  • docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md +17 −5

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (19 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (174 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	4.131s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.993s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.765s
ok  	github.com/carlosframework/platform/internal/activator	59.239s
ok  	github.com/carlosframework/platform/internal/agentsock	0.027s
ok  	github.com/carlosframework/platform/internal/alerting	0.006s
ok  	github.com/carlosframework/platform/internal/analytics	0.046s
ok  	github.com/carlosframework/platform/internal/appconfig	3.191s
ok  	github.com/carlosframework/platform/internal/apperr	0.128s
ok  	github.com/carlosframework/platform/internal/applog	0.236s
ok  	github.com/carlosframework/platform/internal/assetcache	0.144s
ok  	github.com/carlosframework/platform/internal/awsinv	0.016s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.019s
ok  	github.com/carlosframework/platform/internal/boxid	1.029s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.013s
ok  	github.com/carlosframework/platform/internal/caps	0.019s
ok  	github.com/carlosframework/platform/internal/certwarm	0.019s
ok  	github.com/carlosframework/platform/internal/cgroup	0.827s
ok  	github.com/carlosframework/platform/internal/changelog	0.492s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.396s
ok  	github.com/carlosframework/platform/internal/clamd	0.069s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	7.675s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.015s
ok  	github.com/carlosframework/platform/internal/dbquery	0.008s
ok  	github.com/carlosframework/platform/internal/dbreads	2.082s
?   	github.com/carlosframework/platform/internal/dbreads/dbreadstest	[no test files]
ok  	github.com/carlosframework/platform/internal/dbverify	0.013s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.006s
ok  	github.com/carlosframework/platform/internal/docsite	0.009s
ok  	github.com/carlosframework/platform/internal/domains	3.211s
ok  	github.com/carlosframework/platform/internal/doorkey	0.013s
ok  	github.com/carlosframework/platform/internal/economics	0.012s
ok  	github.com/carlosframework/platform/internal/edge	45.716s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.136s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.017s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.159s
ok  	github.com/carlosframework/platform/internal/host	146.123s
ok  	github.com/carlosframework/platform/internal/hostactors	7.165s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.007s
ok  	github.com/carlosframework/platform/internal/hostbox	2.942s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.716s
ok  	github.com/carlosframework/platform/internal/hostenv	0.008s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.010s
ok  	github.com/carlosframework/platform/internal/hostexec	5.415s
ok  	github.com/carlosframework/platform/internal/hostfence	0.060s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.402s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.022s
ok  	github.com/carlosframework/platform/internal/hostguard	1.033s
ok  	github.com/carlosframework/platform/internal/hostops	1.925s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.004s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.005s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.617s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.207s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.003s
ok  	github.com/carlosframework/platform/internal/infraview	0.005s
ok  	github.com/carlosframework/platform/internal/instancectl	5.232s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.844s
ok  	github.com/carlosframework/platform/internal/instancefs	0.005s
ok  	github.com/carlosframework/platform/internal/instancekey	0.004s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.009s
ok  	github.com/carlosframework/platform/internal/layout	1.690s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.003s
ok  	github.com/carlosframework/platform/internal/lease	10.182s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.541s
ok  	github.com/carlosframework/platform/internal/ledger	0.014s
ok  	github.com/carlosframework/platform/internal/mail	0.242s
ok  	github.com/carlosframework/platform/internal/maintenance	0.105s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.605s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.103s
ok  	github.com/carlosframework/platform/internal/metrics	0.015s
ok  	github.com/carlosframework/platform/internal/moves	22.766s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.004s
ok  	github.com/carlosframework/platform/internal/objstore	0.254s
ok  	github.com/carlosframework/platform/internal/ops	0.239s
ok  	github.com/carlosframework/platform/internal/park	0.281s
ok  	github.com/carlosframework/platform/internal/plans	0.008s
ok  	github.com/carlosframework/platform/internal/privatefile	0.005s
ok  	github.com/carlosframework/platform/internal/provision	24.503s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.013s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.608s
ok  	github.com/carlosframework/platform/internal/relay	7.242s
ok  	github.com/carlosframework/platform/internal/release	26.589s
ok  	github.com/carlosframework/platform/internal/rollout	3.852s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.008s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.060s
ok  	github.com/carlosframework/platform/internal/rootstate	0.030s
ok  	github.com/carlosframework/platform/internal/runs	0.605s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.630s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/engines	2.540s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	11.192s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.708s
ok  	github.com/carlosframework/platform/internal/status	0.075s
ok  	github.com/carlosframework/platform/internal/steering	0.008s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.307s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
ok  	github.com/carlosframework/platform/internal/unixdial	0.004s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.006s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.688s
=== internal_console@0 (87 s)
ok  	github.com/carlosframework/platform/internal/console	84.740s
=== internal_console@1 (42 s)
ok  	github.com/carlosframework/platform/internal/console	40.409s
=== internal_console@2 (44 s)
ok  	github.com/carlosframework/platform/internal/console	42.329s
=== internal_console@3 (47 s)
ok  	github.com/carlosframework/platform/internal/console	45.294s
=== internal_console@4 (43 s)
ok  	github.com/carlosframework/platform/internal/console	40.891s
=== internal_console@5 (40 s)
ok  	github.com/carlosframework/platform/internal/console	37.685s
=== internal_console@6 (45 s)
ok  	github.com/carlosframework/platform/internal/console	43.240s
=== internal_console@7 (47 s)
ok  	github.com/carlosframework/platform/internal/console	45.205s
=== root@0 (111 s)
ok  	github.com/carlosframework/platform	109.406s
=== root@1 (97 s)
ok  	github.com/carlosframework/platform	95.726s
=== root@2 (80 s)
ok  	github.com/carlosframework/platform	78.543s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50725026 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.024s
=== RUN   TestT4StorageProbeMinio
2026/10/25 00:07:06 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E19D11DAB48CF4, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.022s
=== RUN   TestS3StoreMinioContract
=== RUN   TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN   TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.02s)
    --- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
    --- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/release	0.031s
Bucket created successfully `local/dbf-deploy`.
Bucket created successfully `local/dbf-data-ie`.
Bucket created successfully `local/dbf-data-uk`.
local/dbf-deploy versioning is enabled
Created policy `dbf-console` successfully.
Created policy `dbf-ukbox` successfully.
Added user `dbfconsole` successfully.
Added user `dbfukbox` successfully.
Attached Policies: [dbf-console]
To User: dbfconsole
Attached Policies: [dbf-ukbox]
To User: dbfukbox
=== RUN   TestDBForwardMinio
2026/10/25 00:07:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/25 00:07:09 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/25 00:07:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/25 00:07:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/25 00:07:09 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/25 00:07:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/25 00:07:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/25 00:07:09 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/25 00:07:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/25 00:07:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/25 00:07:09 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/25 00:07:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/25 00:07:09 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/25 00:07:09 console: could not notify member@example.com of a CLI token issue: keymail: lookup member@example.com: keymail: GET /api/federation/lookup?addr=member%40example.com&from=console%40127.0.0.1%3A42677: could not determine target mailbox
    agent.go:135: dbreads: dbr_xfjzpmnhqxtof2e7mznqwfdlba: planned 3 files (1544 bytes) and restored to 3 in 10ms
    agent.go:135: dbreads: dbr_xfjzpmnhqxtof2e7mznqwfdlba: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/25 00:07:10 console: db read dbr_xfjzpmnhqxtof2e7mznqwfdlba: relayed 1 chunks (102 bytes) in 1.004s, ended done
    agent.go:135: dbreads: dbr_xvndeenxati27h5j7iq5pcxryu: planned 1 files (720 bytes) and restored to 1 in 8ms
    agent.go:135: dbreads: dbr_xvndeenxati27h5j7iq5pcxryu: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/25 00:07:11 console: db read dbr_xvndeenxati27h5j7iq5pcxryu: relayed 1 chunks (102 bytes) in 1.003s, ended done
    agent.go:135: dbreads: dbr_nexosbr4jaq2uuf4l3hqydysqi: planned 3 files (1544 bytes) and held through 3 in 9ms
    agent.go:135: dbreads: dbr_nexosbr4jaq2uuf4l3hqydysqi: output 1544 bytes in 1 confirmed chunks in 1ms
2026/10/25 00:07:12 console: db read dbr_nexosbr4jaq2uuf4l3hqydysqi: relayed 1 chunks (1641 bytes) in 1.004s, ended done
=== RUN   TestDBForwardMinio/by_disconnect
=== NAME  TestDBForwardMinio
    agent.go:135: dbreads: dbr_w3uxwjgt53imudgy65wkouh5gy: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/25 00:07:12 console: db read dbr_w3uxwjgt53imudgy65wkouh5gy: relayed 0 chunks (0 bytes) in 13ms, ended client gone
    agent.go:135: dbreads: dbr_w3uxwjgt53imudgy65wkouh5gy: output 5 bytes in 1 confirmed chunks in 18ms
=== RUN   TestDBForwardMinio/by_timeout
=== NAME  TestDBForwardMinio
    agent.go:135: dbreads: dbr_meowpmopqymtac24u6det4z7ve: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/25 00:07:32 console: db read dbr_meowpmopqymtac24u6det4z7ve: relayed 0 chunks (0 bytes) in 19.723s, ended no-result
    agent.go:135: dbreads: dbr_meowpmopqymtac24u6det4z7ve: output 5 bytes in 1 confirmed chunks in 19.699s
--- PASS: TestDBForwardMinio (23.08s)
    --- PASS: TestDBForwardMinio/by_disconnect (0.14s)
    --- PASS: TestDBForwardMinio/by_timeout (19.83s)
PASS
ok  	github.com/carlosframework/platform/internal/console	23.096s
minio contract: PASS
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 e69b5b20d..28b191008 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
@@ -2,13 +2,13 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-**Goal:** The operator-only per-app policy object (`control/app-policy/<sqid>/<app>.json`), 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.
+**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, the one-writer lease, 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` / `routes.policy_expires_at` 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): 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. Only the console holding the bucket's policy writer lease signs (ruling 10): the platform's one lease mechanism, `internal/lease` (`control/app-policy-lease/lock.json`), reused as the housekeeper reuses it. The console writes through one CAS door gated by `apiSystemRoller`, auditing first. Every policy write bumps the app's change-index scope (ruling 11, spec §3a), so the agent's `provision.Reconcile` re-reads an app's policy only when that app's generation moved, plus one LIST of `control/app-policy/` on the 30-minute backstop; it verifies what it reads 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.
+**Tech Stack:** Go, SQLite (modernc), `net/netip`, `crypto/ed25519`, the existing `release.Store` contract and change index (`release.ChangeScope`, `ChangeWatch`), `internal/lease` (litestream's S3 leaser).
-**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 8, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`.
+**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 9, approved; read §0 rulings 9 to 11 and §3a first; passages marked "[Deferred by ruling 9]" are not in this build). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`. The change index: `docs/superpowers/specs/2026-10-21-s3-change-index-design.md`.
## Global Constraints
@@ -16,21 +16,23 @@
- Local tests are targeted: only touched packages, with `-run`. Never `make test`, never the whole console or host suite locally; CI runs the full gate (`.amadan/test-gate.sh`).
- New tests in `internal/host`, `internal/activator`, `internal/console`, `moveprobe` call `t.Parallel()` unless they set env, cwd, umask, log output or package vars, reach `edge.SecureUnixListen`, or listen on TCP (activator). No test depends on another having run. A new console test slower than a few seconds refreshes `.amadan/test-weights.tsv` with `.amadan/test-weights.sh`.
- Cross-compile: CI builds darwin and windows. Nothing in this plan may need `//go:build linux`; the new packages are pure Go.
-- Root code: this plan changes packages in root's build graph (`internal/registry`, `internal/provision`, `internal/release`, `internal/cgroup`, and the new `internal/apppolicy` that plan 2's root imports), so it ends with a census overlay (Task 13), loaded after `plan14-root-reads-ci-speed-activator`.
+- Root code: this plan changes packages in root's build graph (`internal/registry`, `internal/provision`, `internal/release`, `internal/cgroup`, and the new `internal/apppolicy`), so it ends with a census task (Task 14) in the #369 approval flow (AGENTS.md "The census: an approval, not a decision"): `make census-card`, a CLAIMS file, `make census-propose NAME=<branch> CLAIMS=<file>`, the card shown to Paul. The overlay is appended to `testdata/plan14-root-reads-chain.tsv`, so it loads after `ci-speed-activator`, `securelisten-337` and every overlay already chained.
+- Request budget (spec §3a, ruling 11): no reader polls the policy on its own timer. Steady state with nothing written, added per box per day: at most 48 LISTs (the backstop's `ListETags` of `control/app-policy/`) and 0 GETs; per console per day: 2 GETs per live policy (the re-sign) and no LIST of its own (plan 2's report owns the console's 48). Tasks 7 and 10 pin these with counting-store tests that fail on any increase. The writer lease's own requests go through `internal/lease`'s S3 client, not `release.Store`, and are stated in Task 8.
+- One policy writer per bucket (ruling 10): nothing signs a policy (door write or re-sign) without the writer lease held (Task 8). A second console refuses and names the holder.
- Production-wired witnesses: the fleet-door read is tested through a real `edge.Edge` store door and the real `fleetapi` client; the door through the real console mux; reconcile through a real `DirStore` and registry.
-- User-facing strings (CLI output, door error messages a person reads, audit details shown in the activity tab, README prose) are collected in Task 14 for copy review before merge. Plain English, no em dashes. Internal error fragments, log lines and JSON keys are not copy.
+- User-facing strings (CLI output, door error messages a person reads, audit details shown in the activity tab, README prose) are collected in Task 15 for copy review before merge. Plain English, no em dashes. Internal error fragments, log lines and JSON keys are not copy.
- No tenant names in new code, tests or comments: `shop`, `shop-a.example.com`, `10.0.5.20`, `fd00:5::20`. (Existing fixtures that carry other names are reused as they are.)
- Commit per task, message ending `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
-- Nothing is on by default: with no policy object every registry row, fingerprint, edges file, unit and slice is byte-identical to main (Task 12 proves this plan's part).
+- Nothing is on by default: with no policy object every registry row, fingerprint, edges file, unit and slice is byte-identical to main (Task 13 proves this plan's part).
- Pinned names (brief, "Cross-plan interfaces") are used exactly. Names this plan adds are pinned in each task's Interfaces block.
## Review Focus
-1. **A host name deleted and recreated** keeps its old override in the object. Expect: the new instance runs at the app default; `apps show` marks the entry stale; the next policy write drops it. Tests: Task 7 `TestReconcileIgnoresAnOverrideForAnotherIncarnation`, Task 8 `TestPolicyGetMarksAndPutDropsStaleOverrides`.
+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 9 `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) 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`.
+3. **A second console on the same bucket** (an HA pair someone set up, a restored console left running). Expect: it refuses every policy write and re-sign, naming the holder; once the holder's lease lapses it takes the lease and builds on the bucket's verified record, never on its own stale table. (Two operators on one console still get 412: Task 9 `TestPolicyPutIsCompareAndSwap`.) Test: Task 10 `TestPolicyWriterLeaseIsOneConsolePerBucket`.
+4. **A transient store failure during a policy read, or a box whose change index is disarmed.** Expect: existing columns left as they are (a revoke is not invented, an allow is not dropped) until the recorded expiry passes, then cleared; the failed read is retried on the next pass; a corrupt or refused object, by contrast, fails closed; a disarmed box converges a change at the next backstop (within 33 minutes), never by polling. Tests: Task 7 `TestReconcilePolicyReadFailures`, `TestPolicyReadsWithTheIndexDisarmedWaitForTheBackstop`.
+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 12 `TestAppsSetPolicyRefusesClientSide`.
## Boundary verification (spec §3 precondition), recorded
@@ -39,8 +41,9 @@ What was checked on main at `b06ce419`, and what this plan does about it:
- **Bucket policy and IAM (`infra/`).** `infra/modules/deployment-buckets/main.tf` carries no Deny on `control/` beyond box evidence and fences. Writes come from IAM grants: the box role (`region-host/main.tf:166`, `region-remote-host/main.tf:85`, `s3:PutObject`/`DeleteObject` on `deployment_bucket/*`) and the console, which runs on a box. Object-store credentials an app receives are scoped to `store_bucket_prefix*` buckets (`region-host/main.tf:606-622`), never the deployment bucket. App tokens, instance tokens, deploy credentials (#229) and member sessions are console bearer tokens with no bucket credential. **So no app, build, instance or member credential can write `control/app-policy/` directly. Holds; no infra change.**
- **Store door (`internal/edge/storedoor.go:529`).** `aclWrite` grants only `fleets/<fleet>/**` and `apps/<sqid>/<app>/{logs,errors,analytics}/**`. It never answers true for `control/`. **Holds;** Task 5 pins it with matrix rows.
- **Console mutation boundary (`internal/hostops/mutation_boundary.go`, `mutation_classifier.go:100-160`).** With host claims on, `s.cfg.Releases` is the boundary, and an unreviewed key is refused (`ErrUnclassified`). `control/app-policy/` is unreviewed today, so **the console's own policy door would be refused under claims.** Task 6 classifies it as app-scoped (the `control/door-keys/` shape) for `Put` and `Delete` only.
+- **The writer lease (Task 8).** `control/app-policy-lease/lock.json` in the deployment bucket, written by `internal/lease` through its own S3 client (the housekeeper's `control/housekeeper-lease/lock.json` precedent), so it never passes the console's mutation boundary and needs no classifier row, as the housekeeper's lock does not. The console's role already has `s3:GetObject`/`PutObject`/`DeleteObject` on `deployment_bucket/*` (`region-host/main.tf:166`). **One gap, stated:** where `store_bucket_prefix` is set (flagship), the store policy's explicit Deny of `s3:DeleteObject` on the deployment bucket (`region-host/main.tf:669`, the 2026-09-09 lease outage; hand-fixed on the live role, still on main) refuses the lease's release, so a restarted console waits out `lease.LeaseTTL` (15 minutes) before it may write policies again. No infra change in this plan; it is raised with the spec author.
- **Console doors.** After this plan, only four call sites touch the key: the operator door and the re-sign pass (writes, both in `apipolicy.go`), the purge sweep (delete) and app create (delete). Task 6 adds a source test that keeps it so.
-- **The agent can write the key.** The agent (`carlos-edge.service`, no `IPAddressDeny`) reaches IMDS and so holds the box role, which can write `control/app-policy/`. The same is true of flat exec children and of `carlos-app@` units granted `host_role`. Before claims this is the trust the spec already gives the agent. Under claims it is not, so spec rev 8 §3 makes the object a signed record on the `internal/doorkey` precedent (discussion #171): Task 3 signs and verifies, Task 8 signs on every write, Task 9 re-signs, Task 7 verifies before converging, and plan 2 verifies in root with a revision floor. A bucket writer can then only delete the object, replay an older signed one, or corrupt it; each of those closes allows rather than opening one.
+- **The agent can write the key.** The agent (`carlos-edge.service`, no `IPAddressDeny`) reaches IMDS and so holds the box role, which can write `control/app-policy/`. The same is true of flat exec children and of `carlos-app@` units granted `host_role`. Before claims this is the trust the spec already gives the agent. Under claims it is not, so spec §3 makes the object a signed record on the `internal/doorkey` precedent (discussion #171): Task 3 signs and verifies, Task 9 signs on every write, Task 10 re-signs, and Task 7 verifies before converging. A bucket writer can then only delete the object, replay an older signed one, or corrupt it. Root reads none of it under claims in this build: ruling 9 defers claim-mode egress, so with claims on root writes no allow at all (plan 2 Task 6); the root-side verification, revision floor and incarnation binding are that follow-up's.
## Interfaces produced for plans 2 and 3
@@ -48,7 +51,11 @@ 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, []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`.
+- 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"; the agent's reads go through it via `Get`; root calls nothing here in this build, ruling 9); `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)`; `apppolicy.ListETags(ctx, s) (map[string]string, error)` (key `<sqid>/<app>` to the object's ETag, one listing; `errors.ErrUnsupported` from a store that is not a `release.ETagLister`); `(*Policy).SizeFor(host string, createdAt time.Time) string`; `(*Policy).EgressColumn() string`.
+- `release.ChangeScope("control/app-policy/<sqid>/<app>.json") == "apps/<sqid>/<app>", true` (Task 3, spec §3a): every policy write and delete bumps the app's generation through the concrete stores' existing hook.
+- `provision.PolicyCache` (Task 7): the agent's long-lived policy reader, `NewPolicyCache(watch *release.ChangeWatch) *PolicyCache`, set as `ReconcileConfig.Policies`. Plans 2 and 3 never read the store for policy; they read the columns.
+- The writer lease (Task 8): `console.PolicyLeaser` and `Config.PolicyLease`; `const appPolicyLeasePrefix = "control/app-policy-lease"`; `func (s *Server) policyWriter(ctx context.Context) (policyHolding, *doorRefusal)`; `func (s *Server) ReleasePolicyWriter(ctx context.Context)`. Plan 2's report runs only while `s.policyHeld()` is true.
+- `func (s *Store) AppPolicyRowsContext(ctx context.Context) ([]AppPolicyRow, error)` (Task 9): every recorded policy with its `synced_etag`; plan 2's report reads the live allows from it, not from the bucket.
- `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.
@@ -74,7 +81,7 @@ Everything in the brief's "Cross-plan interfaces", plus:
`FenceDeny`, in this order (the template line with `localhost` and `multicast` expanded the way systemd does):
`127.0.0.0/8 ::1/128 224.0.0.0/4 ff00::/8 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 240.0.0.0/4 64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10`.
-Error text (copy, Task 14), as named constants in the file:
+Error text (copy, Task 15), as named constants in the file:
| const | text |
|---|---|
@@ -215,8 +222,9 @@ func TestSizesTableIsLiteral(t *testing.T) {
**Files:**
- Modify: `internal/release/store.go:37` (add `ErrRefused` beside `ErrCASConflict`)
- Modify: `internal/release/release.go:255-266` (add `AppPolicyPrefix`, `AppPolicyKey` beside `LedgerAppPrefix`; extend `AppPrefix`'s comment to name it)
+- Modify: `internal/release/changeindex.go:50-75` (`ChangeScope`: the policy row; its doc comment's scope list)
- Create: `internal/apppolicy/apppolicy.go` (the object, store calls), `internal/apppolicy/signed.go` (sign and verify)
-- Test: `internal/apppolicy/apppolicy_test.go`, `internal/apppolicy/signed_test.go`
+- Test: `internal/apppolicy/apppolicy_test.go`, `internal/apppolicy/signed_test.go`, `internal/release/changeindex_test.go` (`TestChangeScope`'s table), `internal/release/changeindex_store_test.go`
**Interfaces:**
- Consumes: Task 1 `egressaddr.Parse`, `MaxAllow`; Task 2 `cgroup.SizeByName`; `release.ValidAccount`, `release.ValidApp`.
@@ -232,13 +240,15 @@ func TestSizesTableIsLiteral(t *testing.T) {
- `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 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 9)
- `func Delete(ctx context.Context, s release.Store, sqid, app string) error`
- `var ErrInvalid = errors.New("apppolicy: invalid policy")`, `ErrUnverifiable = errors.New("apppolicy: policy unverifiable")`, `ErrExpired = errors.New("apppolicy: policy expired")`
- `release.ErrRefused = errors.New("store: refused")`
- `const release.AppPolicyPrefix = "control/app-policy/"`; `release.AppPolicyKey(acct, app string) string` = `AppPolicyPrefix + acct + "/" + app + ".json"`
- `const apppolicy.Prefix = release.AppPolicyPrefix`; `apppolicy.Key` calls `release.AppPolicyKey`
- `func Present(ctx context.Context, s release.Store) (map[string]bool, error)`: the keys listed under `Prefix`
+ - `func ListETags(ctx context.Context, s release.Store) (map[string]string, error)`: one `release.ETagLister.ListETags(ctx, Prefix)`; each key of exactly the `Key(sqid, app)` shape (valid account and app) is returned as `"<sqid>/<app>"` to its ETag, anything else under the prefix is skipped; a store without `ETagLister` returns an error wrapping `errors.ErrUnsupported`
+ - `release.ChangeScope`: a key of exactly four segments `control/app-policy/<a>/<b>.json`, `<a>` non-empty and `<b>` non-empty before the suffix, maps to `apps/<a>/<b>`, ok (spec §3a). Any other `control/app-policy/...` key, and `control/app-policy-lease/lock.json`, is in no scope. Shape only, like the existing rows; the classifier and the readers validate names.
- `func (p *Policy) SizeFor(host string, createdAt time.Time) string` and `func (p *Policy) EgressColumn() string`, both nil-safe
Rules (the `internal/doorkey` shape, `doorkey.go:155-240`, kept line for line where it applies):
@@ -252,7 +262,7 @@ Rules (the `internal/doorkey` shape, `doorkey.go:155-240`, kept line for line wh
- **`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).
+- **`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 9: current + 1, now; Task 10: unchanged, now).
- `SizeFor`: the override for `host` if its `CreatedAt.Equal(createdAt)`, else `DefaultSize`, else `""`. `EgressColumn`: `strings.Join(EgressAllow, " ")`.
- [ ] **Step 1: Write the failing tests**
@@ -325,11 +335,35 @@ func TestSizeForHonoursTheBinding(t *testing.T) {
// SizeFor survives a JSON round trip of the record time (t0 from time.Now(), marshalled and parsed)
}
func TestPresentListsPolicies(t *testing.T) { /* two seeded keys -> both in the map, nothing else */ }
+func TestListETagsIsOneListing(t *testing.T) {
+ // policies for ab12/shop and ab12/web and a stray "control/app-policy/ab12/x.txt":
+ // the map is {"ab12/shop": <etag Get returns>, "ab12/web": <etag>} exactly;
+ // a counting wrapper (forwarding ETagLister) sees one ListETags and no Get;
+ // a wrapper hiding ETagLister -> errors.Is(err, errors.ErrUnsupported)
+}
+```
+
+`changeindex_test.go`, rows added to `TestChangeScope`:
+
+```go
+"control/app-policy/bab/shop.json": "apps/bab/shop", // in scope
+// not in any scope:
+"control/app-policy/bab/shop.json/x", "control/app-policy/bab", "control/app-policy/bab/.json",
+"control/app-policy/bab/shop.txt", "control/app-policy//shop.json", "control/app-policy-lease/lock.json",
+```
+
+`changeindex_store_test.go`:
+
+```go
+func TestAPolicyWriteBumpsTheAppScope(t *testing.T) {
+ // DirStore with EnableChangeIndex(); apppolicy.PutIfMatch for bab/shop, then apppolicy.Delete
+ // -> ReadChangeIndex: entries["apps/bab/shop"] == 2 and no other entry exists
+}
```
-- [ ] **Step 2: Run, fail:** `go test ./internal/apppolicy -run .` → FAIL (package missing).
-- [ ] **Step 3: Implement** the release additions, `signed.go` (file comment: the door-key precedent, spec §3 rev 8; why the domain line; why `Account`/`App` are inside the signature) and `apppolicy.go` (package comment: one operator-only object per app, spec §3; written only by the console's policy door and re-sign loop; verified by the agent before claims and by root after).
-- [ ] **Step 4: Run, pass:** `go test ./internal/apppolicy -run . && go test ./internal/release -run 'TestPurge|TestTrash'` (the release edit must not break purge).
+- [ ] **Step 2: Run, fail:** `go test ./internal/apppolicy -run . && go test ./internal/release -run 'TestChangeScope|TestAPolicyWriteBumpsTheAppScope'` → FAIL (package missing; the policy key in no scope).
+- [ ] **Step 3: Implement** the release additions, the `ChangeScope` row (its comment: spec §3a, so a policy write moves the app's generation and the agent re-reads only that app), `signed.go` (file comment: the door-key precedent, spec §3; why the domain line; why `Account`/`App` are inside the signature) and `apppolicy.go` (package comment: one operator-only object per app, spec §3; written only by the console's policy door and re-sign loop, under the writer lease; verified by the agent before it converges; root reads none of it in this build, ruling 9).
+- [ ] **Step 4: Run, pass:** `go test ./internal/apppolicy -run . && go test ./internal/release -run 'TestPurge|TestTrash|TestChangeScope|TestAPolicyWriteBumpsTheAppScope|TestBumpChangeIndex'` (the release edits must not break purge or the index).
- [ ] **Step 5: Commit** `apppolicy: the signed operator policy record (#328, #329)`.
---
@@ -468,7 +502,7 @@ func TestClassifyAppPolicyIsExact(t *testing.T) {
// 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/PutWire/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 9, and the re-sign pass, Task 10)
// 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
@@ -476,7 +510,7 @@ func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
}
```
-- [ ] **Step 2: Run, fail:** `go test ./internal/hostops -run TestClassifyAppPolicyIsExact` → FAIL (unclassified). (The writers test passes vacuously until Tasks 8, 9 and 10 add the calls; it is written now so they land under it.)
+- [ ] **Step 2: Run, fail:** `go test ./internal/hostops -run TestClassifyAppPolicyIsExact` → FAIL (unclassified). (The writers test passes vacuously until Tasks 9, 10 and 11 add the calls; it is written now so they land under it.)
- [ ] **Step 3: Implement** the case with `appScoped("app-policy", p[2], strings.TrimSuffix(p[3], ".json"))` after checking `len(p) == 4`, the `.json` suffix, the method (`Put` or `Delete`), and `t.Key == release.AppPolicyKey(...)`.
- [ ] **Step 4: Run, pass:** `go test ./internal/hostops -run 'TestClassify|TestReviewed' && go test ./internal/apppolicy -run TestPolicyWriters`.
- [ ] **Step 5: Commit** `hostops: classify the app policy as app-scoped (#328)`.
@@ -487,24 +521,31 @@ func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
**Files:**
- Modify: `internal/provision/reconcile.go`: the prefetch block (954-1040) gains the policy read; `convergeOwned` (706-816) gains a parameter and a converge arm after the update-settings arm; its four call sites (1302, 1436, 1451, and the adoption arm at 1419-1436); the main mint (1253-1293); `mintHeld` (1580-1620)
-- Modify: `internal/provision/reconcile.go:36-110` (`ReconcileConfig` gains `PolicyChanged`; `ReleasePub` is already there, line ~94)
+- Modify: `internal/provision/reconcile.go:36-110` (`ReconcileConfig` gains `PolicyChanged` and `Policies`; `ReleasePub` is already there, line ~94)
- Modify: `internal/provision/seedswap.go:150-161` (`swapImport` carries the columns on the import's row)
-- Create: `internal/provision/apppolicy_reconcile.go` (the read, the verification, the column derivation and `PolicyReadStates`)
-- Test: `internal/provision/apppolicy_reconcile_test.go`
+- Modify: `internal/host/agent.go` (the callers of `provisionTickConfig`, line 3270, which builds `provision.ReconcileConfig`: `cfg.Policies` set to one long-lived cache built after the change index is armed, over `armedChangeWatch.Load()`; `provisionTickConfig` itself keeps its `getenv`-only signature)
+- Create: `internal/provision/apppolicy_cache.go` (`PolicyCache`: the gated reads and the backstop), `internal/provision/apppolicy_reconcile.go` (the verification, the column derivation and `PolicyReadStates`)
+- Test: `internal/provision/apppolicy_reconcile_test.go`, `internal/provision/apppolicy_cache_test.go`
**Interfaces:**
-- 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`).
+- Consumes: Task 3 (`apppolicy.ListETags`, `Get`, `ErrUnverifiable`, `ErrExpired`, `SizeFor`, `EgressColumn`, `release.ErrRefused`, the `ChangeScope` row), Task 4 (`SetPolicy`, the three `Route` fields), `apppolicy.Lifetime`, `cfg.ReleasePub` (the box's `CARLOS_RELEASE_PUBKEY`, already in `ReconcileConfig`), `release.ChangeWatch.Gen`, `release.ChangeIndexBackstop`.
- Produces:
- `ReconcileConfig.PolicyChanged func(host string)` (cross-plan: plan 3 sets it to the activator's `NoteSize`)
+ - `ReconcileConfig.Policies *PolicyCache`; `func NewPolicyCache(watch *release.ChangeWatch) *PolicyCache` (nil watch: the index is disarmed or this is a fleet box); `func (c *PolicyCache) read(ctx context.Context, s release.Store, pub ed25519.PublicKey, apps [][2]string, now time.Time) map[[2]string]policyRead`. A nil `Policies` (tests, `carlos` tools) builds a throwaway cache per pass, which therefore runs the backstop every pass: correct, never cheap; production always sets it.
- `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; 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)`
-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`.
+Rules (decisions; spec §4.3 "before claims" and §3a):
+- **Which apps.** Only apps with at least one in-scope instance record this pass (the ones this box mints or keeps rows for). Other apps' policies are never read.
+- **No per-pass read** (ruling 11). The cache keeps, per app, the ETag, the `policyRead` and the generation of `apps/<sqid>/<app>` taken before that read. A pass reads the store for an app only when:
+ 1. **the backstop is due**: at the cache's first pass, then once `release.ChangeIndexBackstop + rand.N(release.ChangeIndexBackstop/10)` has passed since the last successful backstop (30 to 33 minutes, jittered upward only, so a day holds at most 48), or the last backstop failed. The backstop is one `apppolicy.ListETags`, then a `Get` only for each of the pass's apps whose listed ETag differs from the cached one or which has no cache entry; an app the listing does not name is known absent with no read. When `ListETags` fails or is unsupported (a fleet-door box: the door neither lists `control/` nor carries ETags), the backstop is one `Get` per pass app instead. Fleet boxes have no change index (#336 spec §3.4), so this is their only read: one GET per hosted app per backstop, through the console's door;
+ 2. **the app's generation moved**: the watch is usable, the cache has an entry, and `watch.Gen("apps/<sqid>/<app>")` differs from the entry's: one `Get` of that key. A watch that is nil or unusable reads nothing between backstops: ruling 11 has no timer, so a disarmed box converges at the backstop;
+ 3. **the app's last read failed** (`policyTransient`): one `Get` on the next pass, until it succeeds.
+ An app new to the cache between backstops is known absent when the last backstop listing succeeded and did not name it (a later creation moves its generation); otherwise it gets one `Get`. Reads run in the existing prefetch `forEach`, eight at a time. The cached verified policy's expiry is re-checked against `now` on every pass with no read (`policyExpired` once it passes).
+- **Verify before converging** (spec §3): 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), 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.
@@ -541,28 +582,132 @@ 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
+ // transient (a backstop pass): ListETags of the prefix fails and the fallback Get returns a plain
+ // error -> row unchanged; the next pass makes exactly one Get of that key (the retry)
// 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 "" "" 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
- // "control/app-policy/" and exactly one Get under it per pass; with List failing, one Get per app
-}
func TestReconcileDryRunWritesNoPolicy(t *testing.T) { /* cfg.DryRun: row columns unchanged */ }
```
-- [ ] **Step 2: Run, fail:** `go test ./internal/provision -run 'TestReconcileCarriesThePolicy|TestReconcileVerifiesBeforeConverging|TestReconcileGivesSidecarMembersNoPolicy|TestReconcileIgnoresAnOverride|TestReconcilePolicyReadFailures|TestReconcileReadsPoliciesWithOneListing|TestReconcileDryRunWritesNoPolicy'`.
-- [ ] **Step 3: Implement** as the rules above. The `RouteFor` signature does not change: the mint sites set `nr.EgressAllow, nr.Size` from `policyColsFor` after calling it, so the adoption comparisons (`shapeMatches`) are untouched.
-- [ ] **Step 4: Run, pass**, then the neighbours: `go test ./internal/provision -run 'TestReconcile|TestUpdates|TestSeed|TestHold|TestEverywhere'`.
+`apppolicy_cache_test.go` (a `countingStore` wrapping a `DirStore` with `EnableChangeIndex()`, forwarding `ETagLister` and `ConditionalGetter` and counting `List`/`ListETags` under `control/app-policy/` and `Get` of keys under it; a `release.ChangeWatch` over the same store, polled once before each pass as the agent's fast tick does; a fake clock; three apps with records, policies for two):
+
+```go
+// The §3a budget, pinned (ruling 11). Fails on any increase.
+func TestPolicyReadsKeepTheRequestBudget(t *testing.T) {
+ // pass 1 (the first backstop): 1 ListETags, 2 Gets (the two policies), 0 for the third app
+ // reset the counters; 288 passes 5 minutes apart (one day) with nothing written:
+ // ListETags <= 48, Get == 0, List == 0
+ // write app 1's policy (PutIfMatch, which bumps apps/<sqid>/app1); one pass:
+ // exactly 1 Get, of app 1's key; 0 Gets of app 2's; the row converged
+ // a fourth app with records and no policy appears between backstops: 0 Gets for it
+}
+func TestPolicyReadsWithTheIndexDisarmedWaitForTheBackstop(t *testing.T) {
+ // NewPolicyCache(nil): a policy written after pass 1 is not read by passes inside the next
+ // 30 minutes (0 Gets); the pass after the backstop is due reads it (1 ListETags, 1 Get) and converges
+}
+func TestPolicyReadsFallBackToOneGetPerAppWithoutAListing(t *testing.T) {
+ // a store hiding ETagLister (the fleet door's shape): a backstop pass makes one Get per pass app
+ // and no List; between backstops, with no watch, 0 requests
+}
+func TestPolicyCacheExpiresWithoutAReading(t *testing.T) {
+ // a cached verified record; the clock past SignedAt+Lifetime with no backstop due and no
+ // generation move: 0 Gets, the row cleared, PolicyReadStates "policy expired"
+}
+```
+
+- [ ] **Step 2: Run, fail:** `go test ./internal/provision -run 'TestReconcileCarriesThePolicy|TestReconcileVerifiesBeforeConverging|TestReconcileGivesSidecarMembersNoPolicy|TestReconcileIgnoresAnOverride|TestReconcilePolicyReadFailures|TestReconcileDryRunWritesNoPolicy|TestPolicyReads|TestPolicyCache'`.
+- [ ] **Step 3: Implement** as the rules above. The `RouteFor` signature does not change: the mint sites set `nr.EgressAllow, nr.Size` from `policyColsFor` after calling it, so the adoption comparisons (`shapeMatches`) are untouched. In the agent, build the cache once, after `armChangeIndex` has run (so it sees the armed watch, or nil), and hand the same pointer to every pass's config. Comment on `PolicyCache`: ruling 11 and §3a, why a disarmed box waits for the backstop instead of polling, and that the generation is taken before the read (a write landing during it stays due).
+- [ ] **Step 4: Run, pass**, then the neighbours: `go test ./internal/provision -run 'TestReconcile|TestUpdates|TestSeed|TestHold|TestEverywhere'` and `go test ./internal/host -run 'TestProvisionTickConfig|TestChangeIndex'`.
- [ ] **Step 5: Commit** `provision: converge the app policy into the registry (#328, #329)`.
---
-### Task 8: The operator door
+### Task 8: The policy writer lease (ruling 10)
+
+**Files:**
+- Create: `internal/console/policylease.go`, `internal/console/policylease_test.go`
+- Modify: `internal/console/server.go` (`Config` ~483-570 gains `PolicyLease PolicyLeaser` and `PolicyLeaseTTL time.Duration`; `Server` gains the holding state beside `doorPublishLock`)
+- Modify: `cmd/console/main.go` (the `console.Config` literal at ~398: `PolicyLease: policyLeaseFor(os.Getenv)`; a signal handler started after the server is built), `cmd/console/main_test.go`
+
+**Interfaces:**
+- Consumes: `lease.AcquireTTL(ctx, bucket, prefix, region string, ttl time.Duration) (*lease.Held, error)`, `*lease.HeldError{Owner string; ExpiresAt time.Time}`, `lease.LeaseTTL`, `(*lease.Held).Err`, `(*lease.Held).Release`; `s.cfg.Bucket` (the deployment bucket's name, `CARLOS_DEPLOYMENT_BUCKET`).
+- Produces:
+
+```go
+const appPolicyLeasePrefix = "control/app-policy-lease" // the lock is <prefix>/lock.json
+type PolicyLease interface {
+ Err() error
+ Release(ctx context.Context) error
+}
+type PolicyLeaser func(ctx context.Context, bucket, prefix string, ttl time.Duration) (PolicyLease, error)
+// lease.AcquireTTL(ctx, bucket, prefix, "", ttl), returning a nil interface on error
+// (the typed-nil guard acquireDeploymentLease has, internal/host/housekeeper.go:121).
+func ProductionPolicyLeaser(ctx context.Context, bucket, prefix string, ttl time.Duration) (PolicyLease, error)
+type policyHolding struct { n uint64; sole bool }
+func (s *Server) policyWriter(ctx context.Context) (policyHolding, *doorRefusal)
+func (s *Server) policyHeld() bool // non-blocking: a holding exists and its Err() is nil (sole: always)
+func (s *Server) ReleasePolicyWriter(ctx context.Context)
+// cmd/console
+func policyLeaseFor(getenv func(string) string) console.PolicyLeaser // ProductionPolicyLeaser when CARLOS_DEPLOYMENT_BUCKET is set, else nil
+```
+
+Why this mechanism: it is the platform's one election, `internal/lease` (litestream's conditional-write `lock.json`), already reused by the housekeeper with the comment "no second election pattern may be introduced" (`housekeeper.go:9-16`). No store-CAS record of our own, no bespoke TTL.
+
+Rules:
+- **Modes.** `cfg.Bucket == ""` (a `CARLOS_DEPLOYMENT_DIR` deployment) is **sole**, the `housekeeperSole` precedent: a directory store is one console by construction, so `policyWriter` returns holding `{1, true}` and never refuses. `cfg.Bucket != ""` with `cfg.PolicyLease == nil` refuses every call with 503 `policyLeaseMissingMsg` (fail closed: a console that lost the wiring signs nothing). Otherwise the lease.
+- **Lazy, then long-lived.** Nothing is acquired at start. The first `policyWriter` call (a door PUT, Task 9; a re-sign pass with something to sign, Task 10) acquires `appPolicyLeasePrefix` in `cfg.Bucket` with TTL `cfg.PolicyLeaseTTL` (`0` = `lease.LeaseTTL`, 15 minutes); `internal/lease` renews it every TTL/3 for as long as it is held. Later calls return the same holding while its `Err()` is nil. Once `Err()` is non-nil (a renewal lost), the holding ends (`Release` best-effort, log `console: policy writer lease lost: %v`) and that call makes one fresh attempt. Each successful acquisition is a new holding, `n` one above the last. `s.policyLeaseMu` serializes acquisition; it is never held across a policy read.
+- **Refusal names the holder.** `*lease.HeldError` (via `errors.As`) → 503 `policyLeaseHeldMsg` with the lock's `Owner` (litestream's `<hostname>:<pid>`) and `ExpiresAt` as RFC 3339 UTC. Any other acquire error → 503 `policyLeaseUnavailableMsg`, logged with the error.
+- **Release.** `ReleasePolicyWriter` releases the held lease within its own 5 s budget and ends the holding. `cmd/console` calls it from a `signal.Notify(SIGTERM, os.Interrupt)` handler, then `os.Exit(0)`, so a rolled console's successor does not wait out the TTL (the housekeeper's "graceful stand-down must be reachable"). Where the role's `DeleteObject` is denied (Boundary verification), the release fails, is logged, and the successor waits up to 15 minutes.
+- **Cost** (outside `release.Store`; stated, not counted by the §3a tests): nothing on a console that never signs a policy. While held, one conditional PUT per renewal: 288 PUTs a day for the one writer console, independent of apps and boxes. A refused console tries again only on its next need (an operator write or its 12-hour re-sign pass), never on a timer.
+- **What a holding means to the content.** A new holding is "another console may have written since I last did". Task 9's base rules use `n` for that: on the first touch of each app in a holding, the console builds on the bucket's verified record unless the bucket still holds exactly what this console last published.
+
+Copy (Task 15): `policyLeaseHeldMsg` = `another console (%s) is the policy writer for this deployment until %s. Make the change there, or try again after that`; `policyLeaseUnavailableMsg` = `could not take the policy writer lease. Try again`; `policyLeaseMissingMsg` = `this console is not set up to write app policies (no writer lease). Deploy a console built from current main`.
+
+- [ ] **Step 1: Write the failing tests** (`policylease_test.go`, each `t.Parallel()`, a fake `PolicyLeaser` recording its calls and returning a fake `PolicyLease` whose `Err` the test sets)
+
+```go
+func TestPolicyWriterIsSoleWithoutABucket(t *testing.T) {
+ // cfg.Bucket "": policyWriter -> {1, true}, nil; the fake leaser is never called; policyHeld() true
+}
+func TestPolicyWriterRefusesWithoutALeaser(t *testing.T) {
+ // cfg.Bucket "deploy", PolicyLease nil: 503, policyLeaseMissingMsg
+}
+func TestPolicyWriterAcquiresLazilyAndKeepsTheHolding(t *testing.T) {
+ // a new server: 0 leaser calls, policyHeld() false
+ // policyWriter -> 1 call with ("deploy", "control/app-policy-lease", lease.LeaseTTL); holding n 1
+ // policyWriter again -> no call, n 1
+ // fake Err set -> policyWriter: the old lease released once, 1 new call, n 2
+}
+func TestPolicyWriterNamesTheHolder(t *testing.T) {
+ // leaser returns fmt.Errorf("acquire lease: %w", &lease.HeldError{Owner: "console-b:4242", ExpiresAt: t1})
+ // -> 503 whose message contains "another console (console-b:4242)" and t1.UTC().Format(time.RFC3339)
+ // a plain error -> 503 policyLeaseUnavailableMsg
+}
+func TestReleasePolicyWriterEndsTheHolding(t *testing.T) {
+ // held: ReleasePolicyWriter -> fake Release called once, policyHeld() false; a second call is a no-op
+}
+```
+
+`cmd/console/main_test.go`:
+
+```go
+func TestPolicyLeaseForFollowsTheDeploymentBucket(t *testing.T) {
+ // CARLOS_DEPLOYMENT_BUCKET "deploy" -> non-nil; unset (a CARLOS_DEPLOYMENT_DIR deployment) -> nil
+}
+```
+
+- [ ] **Step 2: Run, fail:** `go test ./internal/console -run 'TestPolicyWriter|TestReleasePolicyWriter' && go test ./cmd/console -run TestPolicyLeaseFor` → FAIL (undefined).
+- [ ] **Step 3: Implement.** File comment on `policylease.go`: ruling 10; why `internal/lease` and not a new record (the housekeeper's rule); why lazy (no requests on a deployment without policies); what a holding number is for (Task 9); the flagship `DeleteObject` caveat.
+- [ ] **Step 4: Run, pass.** Same commands, plus `GOOS=windows go build ./cmd/console && GOOS=darwin go build ./cmd/console`.
+- [ ] **Step 5: Commit** `console: one policy writer per bucket, on the platform's lease (#328, #329)`.
+
+---
+
+### Task 9: The operator door
**Files:**
- Create: `internal/console/apipolicy.go`
@@ -571,39 +716,45 @@ func TestReconcileDryRunWritesNoPolicy(t *testing.T) { /* cfg.DryRun: row column
- 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)
+- Modify: `internal/console/claim_invariant_fixtures_test.go` (a door fixture for the PUT, `"route PUT /api/cli/apps/{account}/{app}/policy"`, 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); `claim_invariant_test.go` only if the fixture is staged serially (below)
- Test: `internal/console/apipolicy_test.go`
**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).
+- Consumes: Tasks 1-3, 6, 8 (`policyWriter`, `policyHolding`); `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, 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.
+ - `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, synced_etag TEXT NOT NULL DEFAULT '', 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`). `synced_etag` is the bucket ETag the row last matched: the ETag this console's publication of `wire` got back, or the ETag of a bucket record it adopted; `''` = nothing published from this row (a first write that never landed, or an object found absent).
+ - `type AppPolicyRow struct { AccountID int64; Sqid, App string; Revision uint64; Wire []byte; SyncedETag string }`
+ - `func (s *Store) AppPolicyRecordContext(ctx context.Context, accountID int64, app string) (AppPolicyRow, error)`: the row; the zero row (Revision 0) and nil error when there is none.
+ - `func (s *Store) AppPolicyRowsContext(ctx context.Context) ([]AppPolicyRow, error)`: every row whose `synced_etag` is not `''`, joined to its account's sqid (Task 10's re-sign and plan 2's report read this, so neither lists the bucket).
+ - `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`; `synced_etag` is left as it was. 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) AdoptAppPolicyContext(ctx context.Context, accountID int64, app string, rev uint64, wire []byte, etag string) error`: the bucket's verified record becomes the row's content: `revision = max(revision, rev)`, `wire = wire`, `synced_etag = etag`, inserting when there is none. Revisions still only rise; the next write is one above.
+ - `func (s *Store) SyncedAppPolicyContext(ctx context.Context, accountID int64, app string, rev uint64, etag string) error`: sets `synced_etag` where `revision = rev` (after a publication of that revision landed), else `errAppPolicyRevisionMoved`.
- `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.
+ - Each write is a `claimRaw*` method plus its `withAutoAppSQL` adapter (`"sql.RecordAppPolicy"`, `"sql.AdoptAppPolicy"`, `"sql.SyncedAppPolicy"`, `"sql.ResignAppPolicy"`), the only path the armed gate admits.
+ - `func (s *Server) policyBase(ctx context.Context, acct *Account, app string, h policyHolding, commit bool) (base apppolicy.Policy, etag string, next uint64, row AppPolicyRow, err error)`: step 7's reads and rules below, shared by the GET (`commit` false: nothing recorded, no holding mark), the PUT and Task 10's re-sign (`commit` true).
- 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)
+ - `func (s *Server) policyLock(sqid, app string) *sync.Mutex` (the app's policy mutex, created on first use as `doorPublishLock` is; Task 10 shares it)
- `GET /api/cli/apps/{account}/{app}/policy` → `handleAPIPolicyGet`; `PUT` same path → `handleAPIPolicyPut`. Both `svcrole.Set{}`.
- Request (strict reader, 8 KiB): `type policyPutReq struct { EgressAllow *[]string `json:"egress_allow"`; DefaultSize *string `json:"default_size"`; Size *policySizeReq `json:"size"` }`, `type policySizeReq struct { Host string `json:"host"`; Value string `json:"value"` }`. `null` or absent leaves a field; `[]` clears the allow; `""` clears the default size; `value: "default"` removes the override.
- Reply: `type policyReply struct { Policy apppolicy.Policy `json:"policy"`; ETag string `json:"etag"`; NotChecked []string `json:"not_checked"`; Stale []string `json:"stale_overrides,omitempty"` }`; the `ETag` header carries the same value. An absent object reads as `{V:1}` with `etag ""`.
- `checkEgressGrant(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. 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`;
+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, then `hold, ref := s.policyWriter(ctx)` (Task 8); a refusal is returned as it is (503 naming the holder), before any read, audit or write. Then `policyBase(..., hold, true)`: read the bucket with `apppolicy.GetForUpdate(ctx, s.cfg.Releases, pub, acct.Sqid, app)` (`b`, its bytes, `etag`) and the table with `AppPolicyRecordContext` (row: `h` its revision, `rw` its wire, `se` its synced ETag); 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 10 never revives an absent object, so neither does the door);
+ - **a new holding** (ruling 10's takeover): `hold` is not sole, this app has not been touched in holding `hold.n` (`s.policySynced[sqid+"/"+app] != hold.n`), and the bucket's `etag != se` and its bytes are not `rw`, so another console may have written since this one last did → the bucket is the base: `b` verified → `AdoptAppPolicyContext(b.Revision, its bytes, etag)`, base `b`; unverifiable → `{V:1}` with its ETag (the row's content is not used). Logged `console: app policy %s/%s: new writer holding; building on the bucket's record (revision %d)`. When the bucket still holds what this console last published (`etag == se`, or the bytes are `rw` after a lost reply), the rules below apply as within a holding, so a publication a crash interrupted is still finished (Astra plan R1-1). A sole console never rebases: it is the only writer by construction;
+ - `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: `AdoptAppPolicyContext(b.Revision, its bytes, etag)`, 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.
+ 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 11) 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). With `commit`, `s.policySynced[sqid+"/"+app] = hold.n` once the base is chosen (a map under its own mutex). 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)`; on success `SyncedAppPolicyContext(ctx, acct.ID, app, next, newETag)` (a failure is logged only: the next touch finds the bytes equal to `rw` and treats them as this console's). `ErrCASConflict` (the object changed after step 7, which under the lock and the writer lease only a bucket writer can do: two operators are serialised by the lock and refused at the precondition, a second console by the lease) → 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 10'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)`.
+Audit detail (copy, Task 15): 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 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.
+GET: steps 1-2, then step 7's reads and base rules without writing (`policyBase(..., commit false)`: no adoption is recorded, no holding is marked, and no lease is taken; a console not holding the lease evaluates the rules as a new holding would, so it shows what a takeover would build on); 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`; `the change is recorded but not yet published. Run the command again to publish it now`; `could not read the policy. Try again`.
+Door messages (copy, Task 15): `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)`)
+- [ ] **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)`. `newConsoleFixture` has no `cfg.Bucket`, so its door runs sole; a fixture that sets `Bucket` (the claim-enabled `newClaimCase` among them) also sets `cfg.PolicyLease` to Task 8's fake leaser)
```go
func TestPolicyDoorsAreForSystemOperatorsOnly(t *testing.T) {
@@ -644,7 +795,7 @@ 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;
- // AppPolicyRecordContext == (1, the stored bytes exactly)
+ // AppPolicyRecordContext: Revision 1, Wire the stored bytes exactly, SyncedETag the stored etag
// second PUT -> Revision 2
// 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
@@ -653,6 +804,27 @@ 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
+ // AdoptAppPolicyContext(1, w1'', "e9") over (2, w2) -> the row (2, w1'', "e9"): revision never falls
+ // SyncedAppPolicyContext(2, "e10") -> SyncedETag "e10"; (1, ...) -> errAppPolicyRevisionMoved
+ // AppPolicyRowsContext lists rows with a synced etag only, each with its account's sqid
+}
+func TestPolicyBaseRebasesOnANewHolding(t *testing.T) {
+ // Ruling 10's takeover, with Task 8's fake leaser (cfg.Bucket "deploy").
+ // holding 1: PUT egress ["10.0.5.20"] (rev 1), PUT {"default_size":"small"} (rev 2, synced E2)
+ // another console's record put at the key directly: Revision 2, egress [], default "large"
+ // (signed by the fixture's key; its own table was at 1)
+ // still holding 1: GET -> the recorded rev 2 (10.0.5.20, small): not building on the stored object
+ // the fake's Err set, so the next call is holding 2: GET -> egress none, default "large"
+ // (the bucket's record); PUT If-Match <its etag> {"default_size":"xlarge"} -> stored Revision 3,
+ // no egress_allow, default xlarge; the row (3, that wire, its etag)
+ // interrupted publication across holdings: RecordAppPolicyContext(4, w4) by hand with the bucket still
+ // at rev 3 (etag == SyncedETag); holding 3: GET -> w4's content (finished, never rebased)
+ // the same first sequence on a sole fixture (cfg.Bucket "") with policySynced cleared, as after a
+ // restart: GET -> the recorded rev 2 (a sole console never rebases)
+}
+func TestPolicyDoorRefusesWhileAnotherConsoleHoldsTheLease(t *testing.T) {
+ // fake leaser returning a *lease.HeldError{Owner: "console-b:4242"}: PUT -> 503 naming console-b:4242;
+ // no object written, no event recorded, no row; GET -> 200 (reading needs no lease)
}
func TestPolicyRevokeSurvivesALostWriteAndAReplay(t *testing.T) {
// Astra plan R1-1. PUT egress ["10.0.5.20"] -> rev 1; keep the stored bytes w1
@@ -667,7 +839,7 @@ func TestPolicyRevokeSurvivesALostWriteAndAReplay(t *testing.T) {
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
+ // (Task 11'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
@@ -698,36 +870,40 @@ func TestPolicyPutStampsTheAppIDUnderClaims(t *testing.T) {
}
```
-- [ ] **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.
+**The console invariant (main 521c6ee).** `TestClaimConsoleMutationInvariant` needs the PUT's door fixture, `"route PUT /api/cli/apps/{account}/{app}/policy"`: `apps: claimRefs("bab/titogo")` (the existing fixture's names, reused), a PUT with `If-None-Match: *` and a body setting `default_size`, and its staging sets `cx.srv.cfg.PolicyLease` to Task 8's fake leaser, because the claim case carries a `Bucket`. That is the server's own field, not process-wide state, so the fixture runs in parallel and is **not** listed in `claimSerialDoors`. If an implementer stages the real lease instead (`leasetest.NewS3Server` with `t.Setenv("CARLOS_S3_ENDPOINT", ...)` and the AWS variables, as `internal/host/claimempty_lease_test.go:19-30` does), the fixture is listed in `claimSerialDoors` with the reason `stages the writer lease's S3 endpoint through t.Setenv: process environment, which t.Parallel forbids` and calls `claimMustRunAlone(t, "route PUT /api/cli/apps/{account}/{app}/policy")` before its first `t.Setenv`. Either way the lock object is written by `internal/lease`'s own client, outside the boundary the invariant observes, as the housekeeper's lock is.
+
+- [ ] **Step 2: Run, fail:** `go test ./internal/console -run 'TestPolicy|TestAppPolicyRevisions|TestOpenGatedClassifiesAppPolicyRevisions|TestClaimSQLTableCensus|TestClaimSQLSeamCallers|TestClaimConsoleMutationInvariant|TestClaimSerialDoor|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 five 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, and why a new writer holding builds on the bucket, ruling 10), the registration (comment: `apiSystemRoller` in the handler, the zero Set at the mux), the `closedDoors` lines, the claim fixture as above (add a header-capable request helper next to `claimAPI` if none exists) and the read probe.
- [ ] **Step 4: Run, pass.** Same command.
- [ ] **Step 5: Commit** `console: the app policy door (#328, #329)`.
---
-### Task 9: The re-sign pass, on the door keys' loop
+### Task 10: The re-sign pass, on the door keys' loop
**Files:**
- Modify: `internal/console/apipolicy.go` (`resignAppPolicies`, `resignAppPolicy`)
- Modify: `internal/console/apidoorkeys.go:346-365` (`StartDoorKeyResign` runs `s.resignAppPolicies(ctx)` after `s.resignDoorKeys(ctx)` on each pass; its doc comment names both)
-- Modify: `internal/console/claim_invariant_fixtures_test.go:1861` (the `worker StartDoorKeyResign` fixture's setup also PUTs a policy for `bab/titogo`, so the invariant sees the re-sign's write)
-- Test: `internal/console/apipolicy_resign_test.go`
+- Modify: `internal/console/claim_invariant_fixtures_test.go:1861` (the `worker StartDoorKeyResign` fixture's setup also PUTs a policy for `bab/titogo`, so the invariant sees the re-sign's write, and sets `cx.srv.cfg.PolicyLease` to Task 8's fake leaser: the server's own field, so it stays parallel and out of `claimSerialDoors`, Task 9's rule)
+- Test: `internal/console/apipolicy_resign_test.go`, `internal/console/policylease_twoconsoles_test.go`
**Interfaces:**
-- 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`.
+- Consumes: Task 3 (`Present`, `Key`, `GetForUpdate`, `OpenKey`, `Sign`, `PutWire`, `ErrUnverifiable`), Task 8 (`policyWriter`, `policyHeld`), Task 9 (`policyLock`, `policyBase`, `AppPolicyRowsContext`, `AppPolicyRecordContext`, `SyncedAppPolicyContext`, `ResignAppPolicyContext`), `s.appMutation`, `AccountBySqid`, `doorKeyPublishBudget`.
+- Produces: `func (s *Server) resignAppPolicies(ctx context.Context)`; `func (s *Server) resignAppPolicy(ctx context.Context, acct *Account, app string, hold policyHolding) error`.
-Rules (the door keys' re-sign, `apidoorkeys.go:328-365`, with the console's table as the content's source, Astra plan R1-1):
+Rules (the door keys' re-sign, `apidoorkeys.go:328-365`, with the console's table as the content's source, Astra plan R1-1, and the writer lease, ruling 10):
- 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.
+- **Which apps, with no listing in steady state** (spec §3a). The candidates are `AppPolicyRowsContext`'s rows (every policy this console published or adopted). The pass also lists `control/app-policy/` with `Present` (one LIST) when this process has not listed in its current holding: its first pass, the first pass of a new holding, and every pass while another console holds the lease (so it sees what the holder has before it takes over). A listed key with no row is a candidate too; 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). No candidates → the pass ends with no lease taken.
+- **The lease.** With candidates, `hold, ref := s.policyWriter(ctx)`; a refusal is logged once per pass (`console: app policies: not re-signing; %s`, the refusal's message naming the holder) and nothing is signed.
+- Per app, under `policyLock` and `s.appMutation(ctx, acct, app, "app-policy-resign", ...)`, within `doorKeyPublishBudget`: `policyBase(ctx, acct, app, hold, true)`, Task 9's rules exactly, including the new-holding rebase. Then:
+ - the object is absent → `SyncedAppPolicyContext(row.Revision, "")` (the row leaves the candidates until an operator write), nothing written; an absent object is never recreated (purge, create-time clear);
+ - the base is a row's recorded content → the content to sign is `OpenKey(pub, row.Wire, ...)`, **never the bucket's** within a holding: 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`;
+ - the base is `{V:1}` from an unverifiable object with no usable row → 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: that content at the row's **recorded revision** with `SignedAt = now`; `ResignAppPolicyContext(rev, 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); on success `SyncedAppPolicyContext(rev, newETag)`. 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.
+- **Cost:** per pass, one `Get` per candidate (the base read) and one `Put` per re-signed policy; a `List` only on the passes named above. With the change index enabled each re-sign `Put` bumps the app's scope, so each armed box re-reads that one policy (Task 7) twice a day.
-- [ ] **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")
+- [ ] **Step 1: Write the failing tests** (`t.Parallel()` unless stated; the fixture's release key; records written with `PutIfMatch` directly, and recorded with `RecordAppPolicyContext` and `SyncedAppPolicyContext` where a test says "recorded"; the fixture runs sole (`cfg.Bucket` empty) unless stated)
```go
func TestResignRefreshesSignedAtAndKeepsTheRevision(t *testing.T) {
@@ -757,16 +933,48 @@ func TestStartDoorKeyResignAlsoResignsPolicies(t *testing.T) {
// StartDoorKeyResign with a context cancelled after its first pass -> the policy's SignedAt moved
// a console with no signing key -> StartDoorKeyResign returns at once; the object is untouched
}
+func TestResignWithNoPoliciesTakesNoLease(t *testing.T) {
+ // cfg.Bucket "deploy", the fake leaser: an empty bucket and table -> one Present listing, 0 leaser calls
+}
+// The §3a console budget for the re-sign, pinned (ruling 11). Fails on any increase.
+func TestPolicyResignKeepsTheRequestBudget(t *testing.T) {
+ // a counting wrapper over the fixture's Releases (List/ListETags and Get under control/app-policy/);
+ // three live policies published through the door; reset the counters after the start pass;
+ // two passes (one day): List == 0, ListETags == 0, Get == 6 (2 per live policy)
+ // the fake lease's Err set before the next pass (a new holding): that pass makes exactly 1 List
+}
+```
+
+`policylease_twoconsoles_test.go` (ruling 10's witness; **not** `t.Parallel()`: it sets `CARLOS_S3_ENDPOINT` and the AWS variables with `t.Setenv`, as `internal/host/claimempty_lease_test.go:19-30` does):
+
+```go
+func TestPolicyWriterLeaseIsOneConsolePerBucket(t *testing.T) {
+ // one leasetest.VersionedBucket "deploy" behind leasetest.NewS3Server; two console fixtures A and B
+ // with their OWN store databases, the same release key, Releases = release.NewS3Store(ctx, "deploy")
+ // (EnableChangeIndex) on that server, cfg.Bucket "deploy", PolicyLease ProductionPolicyLeaser,
+ // PolicyLeaseTTL 3s (renewed every second by internal/lease).
+ // 1. B: PUT egress ["10.0.5.20"] -> 200, rev 1 (B holds). B.ReleasePolicyWriter (graceful).
+ // 2. A: PUT egress [] -> 200, rev 2 (A takes the free lease; builds on B's rev 1 from the bucket)
+ // 3. B: PUT {"default_size":"small"} -> 503 whose message names the lock's Owner (read from
+ // control/app-policy-lease/lock.json) and its expiry; the object, B's table and B's events unchanged
+ // 4. B.resignAppPolicies -> the object byte-identical (refused, nothing signed)
+ // 5. A's lease lapses: the bucket's FailOn refuses puts to the lock key, so A's renewals fail;
+ // poll (5 s deadline) until A.policyHeld() is false and the lock's ExpiresAt has passed; clear FailOn
+ // 6. B: GET -> no egress_allow (the bucket's rev 2, not B's stale table, which granted 10.0.5.20);
+ // B: PUT If-Match <that etag> {"default_size":"small"} -> 200, stored Revision 3, no egress_allow,
+ // default_size small; B's row (3, that wire, its etag)
+ // 7. A: PUT -> 503 naming B's owner; A.resignAppPolicies -> nothing signed
+}
```
-- [ ] **Step 2: Run, fail:** `go test ./internal/console -run 'TestResign|TestStartDoorKeyResignAlsoResignsPolicies|TestClaimConsoleMutationInvariant'`.
-- [ ] **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 2: Run, fail:** `go test ./internal/console -run 'TestResign|TestStartDoorKeyResignAlsoResignsPolicies|TestPolicyResignKeepsTheRequestBudget|TestPolicyWriterLeaseIsOneConsolePerBucket|TestClaimConsoleMutationInvariant'`.
+- [ ] **Step 3: Implement.** Comment on `resignAppPolicies`: why the content and the revision both come from the table (Task 9; the door keys' database-as-authority rule, Astra plan R1-1), why only present objects are re-signed (an absent one is a purge or a clear, never revived), why adopting a higher bucket revision is safe (only a console with the release key can have signed it), why the pass lists only once per holding (spec §3a), and that nothing is signed without the writer lease (ruling 10).
+- [ ] **Step 4: Run, pass.** Same command, plus `go test ./internal/console -run 'TestDoorKey'` (the door keys' own pass is unchanged). If the two-console test runs longer than a few seconds, refresh `.amadan/test-weights.tsv` with `.amadan/test-weights.sh`.
- [ ] **Step 5: Commit** `console: re-sign every app policy on the door keys' loop (#328, #329)`.
---
-### Task 10: The purge sweep and the create-time clear
+### Task 11: The purge sweep and the create-time clear
**Files:**
- Modify: `internal/release/trash.go:276-295` (append `AppPolicyKey(acct, app)` to `keys` before the delete loop, with a comment in the ledger paragraph's style)
@@ -775,7 +983,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, 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).
+- 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: under claims root writes no egress allow at all in this build (ruling 9), so a lingering predecessor's policy can at most size the new app's instances (the trust sizes already have, spec §5.3) until the next operator write, which the door starts empty (Task 9 step 7, Astra plan R1-2). The claim-mode follow-up binds the policy to the app's incarnation in root; nothing here reads `app_id` on a box. Both apply to the owner's adopt-existing case too (fail closed: the operator grants again).
- [ ] **Step 1: Write the failing tests**
@@ -805,7 +1013,7 @@ func TestCreatingAnAppClearsAStalePolicy(t *testing.T) {
---
-### Task 11: CLI: `apps set --egress-allow/--size`, `instances set --size`, `apps show`
+### Task 12: CLI: `apps set --egress-allow/--size`, `instances set --size`, `apps show`
**Files:**
- Create: `apps_policy.go` (constants, client calls, `runAppsShow`)
@@ -813,7 +1021,7 @@ func TestCreatingAnAppClearsAStalePolicy(t *testing.T) {
- Test: `apps_policy_test.go`; update callers in `apps_updates_test.go` and the `instances set` tests for the new parameters
**Interfaces:**
-- Consumes: the door (Task 8), `egressaddr.Normalize`, `cgroup.SizeByName`.
+- Consumes: the door (Task 9), `egressaddr.Normalize`, `cgroup.SizeByName`.
- Produces:
- `func (s *apiSession) sendJSONHeader(ctx context.Context, method, path string, in any, hdr http.Header) (int, http.Header, []byte, error)`; `sendJSON` calls it with `nil` and drops the header.
- `type policyChange struct { EgressAllow *[]string `json:"egress_allow,omitempty"`; DefaultSize *string `json:"default_size,omitempty"`; Size *policySize `json:"size,omitempty"` }`, `type policySize struct { Host string `json:"host"`; Value string `json:"value"` }`, `type apiAppPolicy` (the door's reply).
@@ -822,7 +1030,7 @@ func TestCreatingAnAppClearsAStalePolicy(t *testing.T) {
- `appsSetRun(ctx, sess, acct, app, swap *swapPolicyReq, policy updatePolicyReq, change *policyChange) error`; `instancesSetRun(ctx, sess, acct, app, host string, req map[string]any, channel *string, size *string) error` (order: settings, size, channel).
- `carlos apps show <app>`: operator view of the policy.
-Copy (Task 14), as named constants in `apps_policy.go` (the `TestAppsSetCopyIsNamedConstants` walker accepts identifiers):
+Copy (Task 15), as named constants in `apps_policy.go` (the `TestAppsSetCopyIsNamedConstants` walker accepts identifiers):
| const | text |
|---|---|
@@ -846,7 +1054,7 @@ Copy (Task 14), as named constants in `apps_policy.go` (the `TestAppsSetCopyIsNa
| `appsShowNone` / `appsShowBuildSize` / `appsShowStale` | `none` / `the build's size` / ` (not in force. The instance was deleted or recreated)` |
| `appsShowDirectMode` | `apps show works through a console, and this shell is in direct bucket mode (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Log in instead: carlos auth login --console <url>` |
-`appsSetNothing` ("Pass --swap, --swap-concurrency, or both") gains the two flags: an edit to approved copy, listed in Task 14. `appsUsage` gains `show` and its line.
+`appsSetNothing` ("Pass --swap, --swap-concurrency, or both") gains the two flags: an edit to approved copy, listed in Task 15. `appsUsage` gains `show` and its line.
- [ ] **Step 1: Write the failing tests** (`apps_policy_test.go`, against an `httptest` console in the style of `TestAPISessionSetSwapPolicyWireShape`)
@@ -884,7 +1092,7 @@ func TestAppsSetWithoutPolicyFlagsSendsNoPolicyRequest(t *testing.T) {
---
-### Task 12: Nothing set is byte-identical (this plan's part)
+### Task 13: Nothing set is byte-identical (this plan's part)
**Files:**
- Test: `internal/provision/apppolicy_reconcile_test.go` (one test); no production change
@@ -920,32 +1128,32 @@ Expected: all PASS. `TestReconcileMints` compares the whole minted route to a li
---
-### Task 13: Census overlay
+### Task 14: The census, as an approval (#369)
-**Files:**
-- Create: `internal/provision/testdata/plan14-root-reads-instance-policy-1.tsv`
-- Modify: `internal/provision/plan14_root_reads_test.go` (after the `ci-speed-activator` load at line 1100: check its seal, load the new overlay, set the new seal)
-- Create: `docs/superpowers/notes/census-proposal-instance-policy-1.md`
+The flow is AGENTS.md "The census: an approval, not a decision" (merged #369). `NAME` is the branch this plan is built on (`instance-policy-1` unless it is built elsewhere); the tool appends the overlay to `internal/provision/testdata/plan14-root-reads-chain.tsv`, so it loads after `ci-speed-activator`, `securelisten-337` and every overlay already chained. Nothing in `plan14_root_reads_test.go` is edited by hand.
-- [ ] **Step 1: Run the census and read what it reports.** `go test ./internal/provision -run TestPlan14RootReadCensus`. Expected: FAIL naming the changed envelopes and units (at least `internal/apppolicy`, `internal/registry`, `internal/provision`, `internal/release`, `internal/cgroup`, `internal/fleetapi`, `internal/hostops`) and any unit whose digest moved (`scanRoute`, `convergeOwned`, `purgeApp`, ...).
-- [ ] **Step 2: Write the overlay** in the shape of `plan14-root-reads-ci-speed-activator.tsv`: header comments stating that no root read is added (root reads neither column in this plan; plans 2 and 3 add those reads under their own overlays), each moved unit with its executing identity (the agent `carlos-edge.service` user `carlos` for reconcile; the console for the door, classifier and purge; boxes for the auto-purge) and disposition re-frozen at main's class and task, the envelopes re-pinned, and that the seal moves after ci-speed-activator's. Status `PROPOSED-instance-policy-1`.
-- [ ] **Step 3: Write the proposal note** in the shape of `census-proposal-console-switcher-filter.md`: the branch, the base, the units table, the ruling asked of the census owner on #280.
-- [ ] **Step 4: Run, pass:** `go test ./internal/provision -run 'TestPlan14RootReadCensus'`.
-- [ ] **Step 5: Commit** `census: instance-policy-1 overlay, proposed (#280)`.
+**Files:**
+- Scratch (not committed): `$TMPDIR/census-claims-instance-policy-1.tsv`
+- Created by the tool: `internal/provision/testdata/plan14-root-reads-instance-policy-1.tsv`, its line in `plan14-root-reads-chain.tsv`, `docs/superpowers/notes/census-proposal-instance-policy-1.md` (the card)
-If another overlay lands on main first, load this one after it and say so in both files.
+- [ ] **Step 1: Read the card.** `make census-card` (writes nothing). Expected classes: `body` for the moved units (`scanRoute`, `routeArgs`, `convergeOwned`, the prefetch, `purgeApp`, `ChangeScope`, the classifier, `fleetapi.Get`/`Open`, ...), `envelope` for the re-pinned packages (`internal/apppolicy`, `internal/registry`, `internal/provision`, `internal/release`, `internal/cgroup`, `internal/fleetapi`, `internal/hostops`), and `sites` where a unit gains reads of a kind it already makes (`PolicyCache.read`'s `Get` and `ListETags` beside the walk's). Root reads neither new column in this plan.
+- [ ] **Step 2: Write the CLAIMS file**, one line per changed unit the card lists: `<file>:<unit><TAB><what changed, and that no read was added or which one was>`, for example `internal/provision/reconcile.go:convergeOwned body: converges egress_allow, size and policy_expires_at from the verified policy; no read added` and `internal/release/changeindex.go:ChangeScope body: maps control/app-policy/<a>/<b>.json to apps/<a>/<b>; no read added`. The executing identity is in each claim where it is not obvious: the agent (`carlos-edge.service`, user `carlos`) for reconcile and the policy cache, the console for the classifier and purge, boxes for the auto-purge.
+- [ ] **Step 3: Propose.** `make census-propose NAME=instance-policy-1 CLAIMS=$TMPDIR/census-claims-instance-policy-1.tsv`. Expected: it writes the overlay (rows `rev3=census-ruling-instance-policy-1`), the `@legacy` fingerprints, the envelope re-pins, the seal, the chain line and the card, and checks the census is clean. If the card has a decision class (`reads`, `new`, `removed`, `other`), the tool refuses: write those rows by hand following the chained overlays, and the card goes to #280 for the owner's decision, not an approval.
+- [ ] **Step 4: Ask for the approval.** Show Paul the card (`docs/superpowers/notes/census-proposal-instance-policy-1.md`) with one question: approve or hold. If he is not in the session, post the card on #280. Record the approval on #280: who, when, and the tip.
+- [ ] **Step 5: Run, pass:** `go test ./internal/provision -run '^TestPlan14RootReadCensus$' -count=1`.
+- [ ] **Step 6: Commit** the overlay, the chain line and the card: `census: instance-policy-1, for approval (#280, #369)`. If the code changes after this, rerun Step 3 with the same `NAME` (it replaces its own overlay, which must still be the chain's last line) and show the new card. Delete the scratch CLAIMS file.
---
-### Task 14: Strings for copy review, and the rollout note
+### Task 15: Strings for copy review, and the rollout note
**Files:**
-- Modify: `README.md` under "Releasing the platform" (line 243): a dated entry, drafted for review, stating the rollout rule of spec §3 and §7 (every console that writes the bucket runs this version before the first policy is granted; a console downgraded below it must delete `control/app-policy/` first; with nothing granted, the release changes nothing)
+- 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; one console per bucket writes policies, held by the writer lease, ruling 10; 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 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 1: Collect every user-facing string** from: Task 1's error table; Task 9'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 8's three lease refusals (`policyLeaseHeldMsg`, `policyLeaseUnavailableMsg`, `policyLeaseMissingMsg`); Task 12'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 3: Apply the edits verbatim**, rerun `go test . -run 'TestApprovedCopy|TestAppsSet|TestAppsShow|TestChangeAppPolicy'`, `go test ./internal/egressaddr`, `go test ./internal/console -run 'TestPolicy|TestReleasePolicyWriter'`.
- [ ] **Step 4: Add the approved-copy test** pinning the reviewed strings; run it.
- [ ] **Step 5: Commit** `instance policy: approved copy and rollout note`.
@@ -953,17 +1161,29 @@ If another overlay lands on main first, load this one after it and say so in bot
## Self-review
-- **Spec coverage (plan 1's share):** §3 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).
+- **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, 9, 10; sidecar members get no policy (§4.3 rev 8) → Task 7; no below-tier warning (§5.2 rev 8) → Task 12 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, 9, 10, 11. §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 9. §2 CLI goals → Task 12. §7 rollout note → Task 15. "Nothing set" → Task 13. Not here by design: §4.2 checks (plan 2, stub in Task 9), 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.
+- **Spec rev 9 re-check** (run after the dispositions below). Coverage: ruling 10's writer lease → Task 8 (mechanism, refusal naming the holder), Tasks 9 and 10 (no signing without it; a new holding builds on the bucket); ruling 11 and §3a → Task 3 (`ChangeScope` row and its table test), Task 7 (gated reads, backstop, budget test), Task 10 (no steady-state listing, budget test); ruling 9 → Boundary verification and Task 11 (no box reads `app_id`; root reads no policy). The policy itself stays signed and expiring, and the agent still verifies before converging (§3, still needed pre-claim). Names: `AppPolicyRecordContext` now returns `AppPolicyRow` everywhere it is used (Tasks 9, 10); `AdoptAppPolicyContext`, `SyncedAppPolicyContext`, `AppPolicyRowsContext`, `policyBase`, `policyWriter`, `policyHeld`, `ReleasePolicyWriter`, `PolicyLeaser`, `PolicyCache`, `apppolicy.ListETags` are defined once and used with the same signatures in Tasks 3, 7, 8, 9, 10, the "Interfaces produced" list and the brief; `apppolicy.Present` is now used only by Task 10's once-per-holding listing; the per-pass `Present` read in Task 7 is gone. Proportion: the plan grew by one task and two test files; no step carries a body the signature and tests already decide. Placeholders: none.
+- **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, 9 and 10 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-1 (P1) lost counter write resurrects a revoke | Task 9: `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 10: 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 9 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-4 (P1) unclassified table blocks gated startup | Task 9: `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`. |
+
+## Review dispositions (Astra plan R2 + spec rev 9)
+
+| Finding or ruling | What changed |
+|---|---|
+| R2-1 (P1, plan 2) unsigned admission resets root's revision floor | Superseded by ruling 9: root reads no policy under claims in this build, so there is no floor and no admission binding. Plan 1 keeps the console's `app_id` stamp (cheap; the door's R1-2 rule uses it) and no box reads it. Boundary verification and Task 11 say so. |
+| R2-2 (P1) two consoles sign different content at one revision | Replaced by ruling 10's fix. Task 8: one writer per bucket on the platform's lease, `internal/lease` at `control/app-policy-lease/lock.json`, TTL `lease.LeaseTTL` (15 min), renewed every 5 min, released on SIGTERM; a second console refuses (503 naming the lock's owner and expiry). Tasks 9 and 10: nothing is signed without it; a new holding builds on the bucket's verified record unless the bucket still holds this console's last publication (`synced_etag`), so a restarted console still finishes an interrupted publication. Tests: `TestPolicyWriterLeaseIsOneConsolePerBucket` (two console databases, one S3-compatible store, lapse and takeover), `TestPolicyBaseRebasesOnANewHolding`, `TestPolicyDoorRefusesWhileAnotherConsoleHoldsTheLease`. |
+| R2-3 (P1, plan 2) a restart keeps a claim-mode allow past expiry | Superseded by ruling 9 (no claim-mode allow exists). The pre-claim expiry stays on the registry column (Task 4, Task 7). |
+| Ruling 11, spec §3a | Task 3: `ChangeScope` maps the policy key to `apps/<sqid>/<app>` (table rows and a bump test). Task 7: the agent's per-pass LIST and GETs are gone; reads happen on that app's generation move, a failed read's retry, or the backstop (one `ListETags`, GET only changed ETags). Task 10: the re-sign visits the table's rows and lists only once per holding. Budget tests: `TestPolicyReadsKeepTheRequestBudget` (box: at most 48 LISTs and 0 GETs a day with nothing written), `TestPolicyResignKeepsTheRequestBudget` (console: 0 LISTs and 2 GETs per live policy a day). |
+| Console invariant (main 521c6ee) | Task 9: the PUT's fixture in `TestClaimConsoleMutationInvariant` injects a fake leaser on its own server, so it stays parallel; the serial alternative (`claimSerialDoors` plus `claimMustRunAlone`) is spelled out. Task 10's worker fixture does the same. |
+| Census (#369) | Task 14 is the approval flow: `make census-card`, a CLAIMS file, `make census-propose`, the card to Paul; the chain manifest loads it after `ci-speed-activator` and `securelisten-337`. |
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 13aa0e2f2..8eb316c9e 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-2-egress.md
@@ -4,11 +4,11 @@
**Goal:** Root writes an operator-granted `IPAddressAllow=` onto each isolated instance's per-host slice, keeps it equal to policy by read-back, and the fleet can see it in force; the console checks fleet addresses at the grant and reports them afterwards.
-**Architecture:** One root object, `instancectl.EgressAsserter`, owns the slice's allow: it takes a desired list from a source (the registry column before claims; under claims the signed policy root reads from the store itself, verifies against the release key, binds to the app's incarnation and holds to a per-app revision floor in root's own state), validates it with `egressaddr` and the box's own addresses, compares it with `systemctl show`, and writes only on a difference. Every start path calls it, a 30 s loop and a `POST /egress` nudge re-assert it, and `GET /egress` reports it to the agent, which carries it in the status snapshot. Under claims the loop reads each app's policy at most every 30 s, conditional on its ETag. Boxes publish their addresses in the edges file; the console checks a grant against them and re-checks on a timer.
+**Architecture:** One root object, `instancectl.EgressAsserter`, owns the slice's allow: it takes a desired list from a source, validates it with `egressaddr` and the box's own addresses, compares it with `systemctl show`, and writes only on a difference. Before claims the source is the registry column the agent converges (plan 1). With host claims on, egress is deferred (ruling 9): the source is a constant that closes every slice, so root writes no allow and reports `egress: not yet supported under claims`. Root reads no store for egress at all. Every start path calls the asserter, a 30 s loop and a `POST /egress` nudge re-assert it, and `GET /egress` reports it to the agent, which carries it in the status snapshot. Boxes publish their addresses in the edges file; the console checks a grant against them and re-checks on a `control/edges` generation change and on the change index's 30-minute backstop, never on its own timer (ruling 11).
-**Tech Stack:** Go (`net/netip`, `os/exec` systemctl), systemd cgroup BPF firewall, cgroup v2, the deployment store (`release.Store`).
+**Tech Stack:** Go (`net/netip`, `os/exec` systemctl), systemd cgroup BPF firewall, cgroup v2, the deployment store (`release.Store`) and its change index (`release.ChangeWatch`, `release.ETagLister`).
-**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 8, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`. Read §0, §4, §9 and §11 before any task.
+**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 9, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`. Read §0 (rulings 9 to 11 first), §3a, §4, §9 and §11 before any task. Passages marked "[Deferred by ruling 9]" are not in this build.
## Global Constraints
@@ -20,8 +20,10 @@
- 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`, `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.
+- Consume plan 1 only by the names the brief and plan 1's "Interfaces produced" pin: `apppolicy.{Policy,OpenKey,PutIfMatch,Lifetime}`, `egressaddr.{Parse,Allowed,FenceDeny}`, `registry.Route.EgressAllow`, `registry.Route.PolicyExpiresAt` (brief, Astra plan R1-3), `provision.PolicyReadStates`, 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, the console's `AppPolicyRowsContext` and `policyHeld` (plan 1 Tasks 8, 9).
+- **Claims (ruling 9):** with host claims on, root writes no allow: every assert's desired list is empty, so an allow found on a slice is reset, and the host reports `under_claims` (shown as `egress: not yet supported under claims`). No root code reads the store, verifies a policy, keeps a revision floor or reads an app's `app_id`. Task 6 proves it through the real claim wiring.
+- **Request budget (spec §3a, ruling 11):** root reads no store for egress; the agent's nudge and status probe read none; `addrs` rides the edges file's gated write. The console's grant check reads `control/edges/` only on a grant; its report runs on a `control/edges` generation change and on the backstop (at most 48 a day), lists `control/edges/` with ETags, GETs only changed files and reads the live allows from its own table. Steady state with nothing changing, per console per day: at most 48 LISTs and 0 GETs added by this plan. Tasks 8 and 10 pin it with counting-store tests that fail on any increase.
+- **Census (#369):** Task 16 uses `make census-card`, a CLAIMS file, `make census-propose NAME=<branch> CLAIMS=<file>` and the card shown to Paul; the chain manifest loads the overlay after `ci-speed-activator`, `securelisten-337` and plan 1's overlay.
- User-facing strings (CLI output, door error messages, alert text, report words) are drafts until Task 17's copy review. Plain English, no em dashes. Internal error fragments and JSON keys are not copy.
- No tenant names: `shop-a.example.com`, `10.0.5.20`, `fd00:5::20`.
- Commit per task, ending with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
@@ -31,7 +33,7 @@
## Review Focus
1. **systemd's read-back form differs from what root wrote** (order, `/32` dropped, v6 compressed differently). Expected: no rewrite churn every 30 s. Test: `TestEgressReadBackFormsAreOneSet` (Task 2).
-2. **Control service restarts during a store outage under claims, with an allow already on a slice.** Expected: the held allow is left as it is (not reset, not widened) and the host reports `unreadable`. Test: `TestEgressNoCacheAndSourceErrorLeavesTheSliceAlone` (Task 2).
+2. **Host claims switched on for a box whose slices already hold allows** (granted while it ran pre-claim). Expected: the first assert or sweep under claims resets every one, nothing is written on a slice that holds nothing, and each host whose row still carries an allow shows `egress: not yet supported under claims`. Tests: `TestEgressUnderClaimsResetsAHeldAllow` (Task 2), `TestClaimEgressIsClosedThroughTheClaimWiring` (Task 6).
3. **A populated slice whose host root cannot name** (a long host's hashed instance id after a restart, before the registry seed). Expected: its allow is reset, never left open on a guess. Test: `TestEgressSweepResetsAnUnattributableSlice` (Task 4).
4. **An operator removes one address while another already granted is now a fleet box's.** Expected: the revoke goes through; only added addresses are checked. Test: `TestEgressGrantChecksOnlyAddedAddresses` (Task 9).
5. **A box with no non-loopback address, and a box whose procfs read fails.** Expected: `"addrs": []` for the first, `"addrs": null` (treated as lacking, grants refused naming it) for the second. Test: `TestEdgesFileAddrsEmptyAndUnreadable` (Task 8).
@@ -48,12 +50,11 @@
| `internal/instancectl/egress_test.go` (new) | Unit tests with `fakeCtl`. |
| `internal/instancectl/server.go`, `gen.go`, `cmd.go`, `claimunit.go`, `client.go` | Start-path calls, routes, client verbs. |
| `internal/instancectl/localaddrs.go` | Export the box address reader for the agent. |
-| `internal/host/egress_source.go` (new) | Pre-claim and claims sources, the wholly-nested rail. |
-| `internal/host/egress_floor.go` (new) | Root's per-app revision floor under `/opt/carlos/system/guard/app-policy/`. |
-| `internal/release/conditional_get.go` (new) | `GetIfNoneMatch` and `ErrNotModified` for the per-app conditional read. |
+| `internal/host/egress_source.go` (new) | The pre-claim source, the under-claims source, the wholly-nested rail. |
+| `internal/host/egress_claim_wiring.go` (new) | The asserter in the claim branch's runtime resources, before the assembly. |
| `internal/host/egress_nudge.go` (new) | Agent: `POST /egress` after a column change. |
-| `internal/host/edgesfile.go`, `internal/edgesfile/addrs.go` (new) | `addrs` publication and the console's reader. |
-| `internal/console/egressfleet.go` (new) | Grant-time check, report loop, alert, conflict set. |
+| `internal/host/edgesfile.go`, `internal/edgesfile/addrs.go` (new) | `addrs` publication and the console's readers (full, and ETag-cached). |
+| `internal/console/egressfleet.go` (new) | Grant-time check, the gated report, alert, conflict set. |
| `internal/status/snapshot.go`, `internal/host/agent.go` | Per-host egress in the box snapshot. |
| `internal/console/apistatuslayout.go`, `apiinstances.go`, `status_instances.go`, `instances_cli.go`, `apimode.go` | Display. |
@@ -114,23 +115,21 @@ Expected: PASS, with the run id recorded in the task report.
// EgressPolicy is one host's desired allow as a source reads it. Root
// validates every entry; a source never pre-filters.
type EgressPolicy struct {
- Allow []string // entries as stored (registry column split on spaces, or Policy.EgressAllow)
+ Allow []string // entries as stored (the registry column split on spaces)
Closed string // non-empty: write no allow; becomes the report's State
+ Detail string // why, when Closed is set; becomes the report's Detail (Astra plan R2-4)
}
-// EgressSource reads one host's policy. fresh is true on a start path and on
-// POST /egress (read the store now); false from the sweep (a source may
-// answer from its own per-app cache, Task 6). A non-nil error means "could
-// not read it now": the asserter keeps the last policy it read for that host.
-type EgressSource func(ctx context.Context, host string, fresh bool) (EgressPolicy, error)
+// EgressSource reads one host's policy. A non-nil error means "could not read
+// it now": the asserter keeps the last policy it read for that host, and with
+// none cached it closes the slice (Task 2 Step 3).
+type EgressSource func(ctx context.Context, host string) (EgressPolicy, error)
const (
- EgressFailing = "failing" // desired and held differ after a write
- EgressUnreadable = "unreadable" // the policy could not be read or was refused
- EgressUnverifiable = "unverifiable" // the record's signature, shape or key binding failed (spec §3 rev 8)
- EgressExpired = "expired" // signed more than apppolicy.Lifetime ago
- EgressReplayed = "replayed" // revision below root's floor for the app
- EgressOtherIncarnation = "other_incarnation" // policy app_id is not the current app's
- EgressNotNested = "not_nested" // pre-claim box not wholly nested
+ EgressFailing = "failing" // desired and held differ after a write
+ EgressUnreadable = "unreadable" // the source could not be read, or the policy was malformed
+ EgressExpired = "expired" // the recorded expiry has passed (plan 1's policy_expires_at)
+ EgressNotNested = "not_nested" // pre-claim box not wholly nested
+ EgressUnderClaims = "under_claims" // host claims on: egress deferred (ruling 9), no allow written
)
// EgressReport is one host's read-back (spec §4.4).
@@ -151,8 +150,7 @@ type EgressAsserter struct {
// unexported: mu, last map[host]EgressPolicy,
// reports map[host]EgressReport, names map[iid]host
}
-func (e *EgressAsserter) Assert(ctx context.Context, host string) error // fresh: start paths and POST /egress
-func (e *EgressAsserter) assert(ctx context.Context, host string, fresh bool) error // Sweep passes false
+func (e *EgressAsserter) Assert(ctx context.Context, host string) error // start paths, POST /egress and Sweep
func (e *EgressAsserter) Reports() map[string]EgressReport // a copy
func LocalAddresses() ([]netip.Prefix, error) // localPrefixes(fibTriePath, ifInet6Path)
```
@@ -166,15 +164,16 @@ A nil `*EgressAsserter` is valid: `Assert` returns nil and does nothing, `Report
- `TestEgressDropsTheBoxsOwnAddress`: `LocalAddrs` returns `10.0.5.20/32`; written only `10.0.5.21/32`; `Refused: ["10.0.5.20: an address of this box (10.0.5.20/32)"]`.
- `TestEgressClosesWhenTheBoxsAddressesCannotBeRead`: `LocalAddrs` errors; written the reset alone; every entry refused with `this box's addresses could not be read`.
- `TestEgressMoreThanEightClosesAll`: nine valid entries; reset alone; `State: EgressUnreadable`, `Detail` says more than 8.
- - `TestEgressClosedPolicyResets`: for each of `EgressOtherIncarnation`, `EgressUnverifiable`, `EgressExpired`, `EgressReplayed`: `Closed` set with `Allow` set and the slice holding `10.0.5.20/32`; reset written; `State` is that word.
- - `TestEgressAssertReadsFresh`: `Assert` calls the source with `fresh == true`.
+ - `TestEgressClosedPolicyResets`: for each of `EgressExpired`, `EgressNotNested`, `EgressUnderClaims`: `Closed` set with `Allow` set and the slice holding `10.0.5.20/32`; reset written; `State` is that word.
+ - `TestEgressClosedPolicyCarriesItsDetail` (Astra plan R2-4): `EgressPolicy{Closed: EgressExpired, Detail: "the policy expired at 2026-10-24T00:00:00Z"}` → the report's `State` `expired` and `Detail` that string exactly; a source error `errors.New("registry: locked")` → `Detail` contains `registry: locked`.
+ - `TestEgressUnderClaimsResetsAHeldAllow` (Review Focus 2): source answers `EgressPolicy{Closed: EgressUnderClaims}`; a slice holding `10.0.5.20/32` gets the reset alone; a slice holding nothing gets no `set-property` at all (only the `show`); both report `State: under_claims`, no `Allowed`.
- `TestEgressReadBackFormsAreOneSet` (Review Focus 1): desired `10.0.5.20, fd00:5::20`; show answers `fd00:5:0:0:0:0:0:20/128 10.0.5.20`; no `set-property` call.
- `TestEgressFailingAfterAWriteThatDoesNotHold`: fake ignores the write; `Assert` returns an error; `State: EgressFailing`; `Allowed` is what show returned.
- `TestEgressSourceErrorKeepsTheLastPolicy`: first `Assert` with `Allow: {"10.0.5.20"}`, second with a source error; the second writes nothing (held matches the cached policy); `State: EgressUnreadable`, `Allowed: ["10.0.5.20"]`.
- - `TestEgressNoCacheAndSourceErrorLeavesTheSliceAlone` (Review Focus 2): fresh asserter, source errors, slice shows `10.0.5.20/32`; no `set-property`; `State: EgressUnreadable`, `Allowed: ["10.0.5.20"]`.
+ - `TestEgressNoCacheAndSourceErrorCloses`: a fresh asserter (a control-service restart), the source errors, the slice shows `10.0.5.20/32`: the reset is written; `State: EgressUnreadable`; the next `Assert` with a good source writes the allow back.
- `TestEgressNilAsserterIsANoOp`.
- [ ] **Step 2: Run** `TMPDIR=/tmp/g go test ./internal/instancectl -run 'TestEgress' -count=1`. Expected: FAIL, `EgressAsserter` undefined.
-- [ ] **Step 3: Implement** in `egress.go`. `Assert` is `assert(ctx, host, true)`. `assert`: read source (error → cached policy, or leave the slice alone if none, `State: EgressUnreadable`); `Closed` → want empty; else per entry `egressaddr.Parse` then `egressaddr.Allowed` then `isLocal` against `LocalAddrs()`; more than 8 accepted → want empty; want as `a/32` or `a/128`, sorted; read back with `show -p IPAddressAllow --value <slice>`, parse into a set of masked prefixes (bare address = full-length prefix; an unparseable token makes the sets unequal); equal → no write; else one `set-property --runtime <slice> IPAddressAllow= [IPAddressAllow=<want...>]`, read back again, unequal → `EgressFailing` and an error. `Allowed` is the read-back as bare addresses (Task 1 Step 5 decides whether the parent's entries are subtracted). Apply Task 1 Step 6's branches if its verdict requires them. Put Task 1's verdict comment at the top of the file.
+- [ ] **Step 3: Implement** in `egress.go`. `Assert`: read source (error → the cached policy, `State: EgressUnreadable` and `Detail` the error; with no cached policy, want empty: the source is a local file, so a fresh control service that cannot read it closes rather than keeping an allow it cannot vouch for, and reopens on the next good read, within 30 s); `Closed` → want empty, `State` = `Closed`, `Detail` = the policy's `Detail`; else per entry `egressaddr.Parse` then `egressaddr.Allowed` then `isLocal` against `LocalAddrs()`; more than 8 accepted → want empty; want as `a/32` or `a/128`, sorted; read back with `show -p IPAddressAllow --value <slice>`, parse into a set of masked prefixes (bare address = full-length prefix; an unparseable token makes the sets unequal); equal → no write; else one `set-property --runtime <slice> IPAddressAllow= [IPAddressAllow=<want...>]`, read back again, unequal → `EgressFailing` and an error. `Allowed` is the read-back as bare addresses (Task 1 Step 5 decides whether the parent's entries are subtracted). Apply Task 1 Step 6's branches if its verdict requires them. Put Task 1's verdict comment at the top of the file.
- [ ] **Step 4: Run** the Step 2 command. Expected: PASS.
- [ ] **Step 5: Cross-compile** `GOOS=darwin go vet ./internal/instancectl && GOOS=windows go vet ./internal/instancectl`. Expected: clean.
- [ ] **Step 6: Commit** `egress.go`, `egress_test.go`, `localaddrs.go`: `instancectl: the egress asserter (validate, read back, write on difference) (#328)`.
@@ -226,9 +225,9 @@ func RunWithRegistry(args []string, hostApp func(string) (string, string, error)
func RunWithClaim(args []string, handler, heldRelease http.Handler, hostApp func(string) (string, string, error), egress *EgressAsserter)
```
-Sweep: for each populated slice, name it (`Unescape` of the part between `carlos-instances-` and `.slice` gives the instance id; a name `Assert` or `Hosts` recorded for that id gives the host; an id not starting with `_` is the host itself); then `assert(ctx, host, false)`. The asserter keeps no read cadence of its own: a source decides what `fresh == false` costs (the registry source reads the local file every time; the claims source answers from its per-app cache, Task 6). A slice it cannot name gets `IPAddressAllow=` (reset only, and only when it holds something), logged as `egress: <slice>: no host for this slice; its allow is closed`. `POST /egress` and every start path call `Assert` (`fresh == true`). `Loop` seeds names from `Hosts()` once at start (an error is logged, not fatal), sweeps at once, then every `every`; production `every` is 30 s (`DiskEvery`'s value). A host failing three sweeps in a row is logged every sweep while it fails.
+Sweep: for each populated slice, name it (`Unescape` of the part between `carlos-instances-` and `.slice` gives the instance id; a name `Assert` or `Hosts` recorded for that id gives the host; an id not starting with `_` is the host itself); then `Assert(ctx, host)`. Neither source reads a store: the pre-claim source reads the local registry file, the under-claims source reads nothing (Task 6), so a sweep costs no request. A slice it cannot name gets `IPAddressAllow=` (reset only, and only when it holds something), logged as `egress: <slice>: no host for this slice; its allow is closed`. `POST /egress` and every start path call `Assert` too. `Loop` seeds names from `Hosts()` once at start (an error is logged, not fatal), sweeps at once, then every `every`; production `every` is 30 s (`DiskEvery`'s value). A host failing three sweeps in a row is logged every sweep while it fails.
-- [ ] **Step 1: Write the failing tests:** `TestEgressSweepAssertsEveryPopulatedSlice` (fixture cgroup tree as in `server_test.go`'s `CgroupRoot`; two populated, one `populated 0`; only the two are read back); `TestEgressSweepResetsAnUnattributableSlice` (Review Focus 3: `_shop-a-long...-<hash>` slice, no name known, holds `10.0.5.20/32` → reset alone); `TestEgressSweepReadsWithFreshFalse` (the source sees `fresh == false` for every swept host, and `true` from `POST /egress`); `TestEgressRouteAssertsFresh` and `TestEgressRouteRefusesABadHost` (via `newServer`); `TestEgressRoutesServedWithClaimsOn` (`ClaimStop` set: `GET /egress` 200, `POST /start` still 503); `TestClientEgressVerbs` in `client_test.go`.
+- [ ] **Step 1: Write the failing tests:** `TestEgressSweepAssertsEveryPopulatedSlice` (fixture cgroup tree as in `server_test.go`'s `CgroupRoot`; two populated, one `populated 0`; only the two are read back); `TestEgressSweepResetsAnUnattributableSlice` (Review Focus 3: `_shop-a-long...-<hash>` slice, no name known, holds `10.0.5.20/32` → reset alone); `TestEgressRouteAsserts` and `TestEgressRouteRefusesABadHost` (via `newServer`); `TestEgressRoutesServedWithClaimsOn` (`ClaimStop` set: `GET /egress` 200, `POST /start` still 503); `TestClientEgressVerbs` in `client_test.go`.
- [ ] **Step 2: Run** `TMPDIR=/tmp/g go test ./internal/instancectl -run 'TestEgressSweep|TestEgressRoute|TestClientEgress' -count=1`. Expected: FAIL.
- [ ] **Step 3: Implement**; update `internal/host/claimstop_run.go:36,87` to pass `nil` for now (Tasks 5 and 6 wire them).
- [ ] **Step 4: Run** Step 2's command, then `go build ./... && GOOS=windows go build ./... && GOOS=darwin go build ./...`. Expected: PASS, builds.
@@ -252,7 +251,7 @@ 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, now func() time.Time) instancectl.EgressSource // ignores fresh: the registry is a local file
+func registryEgressSource(dataDir string, now func() time.Time) instancectl.EgressSource // the registry is a local file: no store read
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
```
@@ -271,97 +270,43 @@ Spec §4.3 rev 8: a `Kind "sidecar"` row is outside the layout pass (`layoutleg.
---
-### Task 6: Claims source: the signed policy, the revision floor, the per-app conditional read, the `app_id` binding, and the claim launch wiring
+### Task 6: Under claims, no allow (ruling 9), through the real claim wiring
**Files:**
-- Create: `internal/release/conditional_get.go`, `internal/release/conditional_get_test.go`
-- Modify: `internal/release/store.go` (`S3Store` and `DirStore` implement `ConditionalGetter`)
-- Create: `internal/host/egress_floor.go`, `internal/host/egress_floor_test.go`
-- Modify: `internal/host/egress_source.go`, `egress_source_test.go`, `claimstop_run.go` (claims branch 38-88), `claimconstruction.go:40-45` (`ClaimRuntimeResources` gains `Egress *instancectl.EgressAsserter`), `claimunit_production.go:34-...` (`bindProductionClaimUnits`: set `base.Egress` from the runtime resources before `base` is wrapped)
-- 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`
+- Modify: `internal/host/egress_source.go` (`claimsEgressSource`, `claimsEgressAsserter`), `egress_source_test.go`
+- Modify: `internal/host/claimstop_run.go` (claims branch 38-88: the asserter goes into the `ClaimRuntimeResources` value before `withClaimRuntimeResources`, and to `RunWithClaim`), `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)
+- Create: `internal/host/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`).
+- Consumes: `newEgressAsserter`, `registryEgressHosts` (Task 5); `withClaimRuntimeResources(ctx, r ClaimRuntimeResources)` (`claimconstruction.go:114`), `ensureProductionClaimAssembly`, `bindProductionClaimUnits`; `RunWithClaim` (Task 4).
- Produces:
```go
-// internal/release
-var ErrNotModified = errors.New("store: not modified")
-type ConditionalGetter interface {
- GetIfNoneMatch(ctx context.Context, key, etag string) ([]byte, string, error) // ErrNotModified when the object's etag is etag
-}
-// GetIfNoneMatch: etag "" is a plain Get; a store that is not a ConditionalGetter
-// (fleetapi, test fakes, wrappers) falls back to Get and compares the etag itself.
-func GetIfNoneMatch(ctx context.Context, s Store, key, etag string) ([]byte, string, error)
-
-// internal/host
-func appPolicyFloorDir(opt string) string // filepath.Join(systemGuardDir(opt), "app-policy")
-type policyFloors interface { // rootstate.Store satisfies it
- Read(name string) ([]byte, error)
- Write(name string, body []byte) error
-}
-type appPolicyFloor struct { AppID string `json:"app_id"`; Revision uint64 `json:"revision"` } // file "<sqid>.<app>.json"
-func newStoreEgressSource(hostApp func(string) (string, string, error), store release.Store,
- pub ed25519.PublicKey, floors policyFloors, now func() time.Time) instancectl.EgressSource
-const claimPolicyEvery = 30 * time.Second
-
-// 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)
+const claimsEgressDetail = "not yet supported under claims" // REASON renders it as "egress: not yet supported under claims"
+// claimsEgressSource answers every host EgressPolicy{Closed: instancectl.EgressUnderClaims,
+// Detail: claimsEgressDetail}. It reads nothing: no store, no registry column, no file the agent writes.
+func claimsEgressSource() instancectl.EgressSource
+// claimsEgressAsserter is newEgressAsserter(claimsEgressSource(), hosts).
+func claimsEgressAsserter(hosts func() ([]string, error)) *instancectl.EgressAsserter
```
-The source keeps, under one mutex, per host the `(acct, app)` `hostApp` last answered, and per app the last read: ETag, the verified policy (or "absent"), the admission's `app_id`, and when it was read.
-
-Rules, in order, for `source(ctx, host, fresh)`:
-1. **Which app.** `fresh`, or no cached answer for the host: `hostApp(host)`; `os.ErrPermission` or `os.ErrNotExist` → `Closed: EgressUnreadable`; other error → error. Otherwise the cached `(acct, app)`.
-2. **Whether to read.** Read the store when `fresh`, or the app has no cached read, or its cached read found a policy (any content) and is at least `claimPolicyEvery` old. An app whose last read found no object is read again on a start path, on `POST /egress`, or once `claimPolicyAbsentEvery` (5 minutes) has passed, so the loop costs one conditional request per app with a policy per 30 s and one per app without one per 5 minutes (spec §3 rev 8). A first grant to an app whose instances are already running reaches them through the agent's `POST /egress` (Task 7) at once, and within 5 minutes whatever the agent does; a change or revoke of an existing policy reaches them within 30 s whatever the agent does. Test: `TestClaimPolicyAbsentAppIsReadAgainAfterFiveMinutes` (fake clock: no read at 4m59s, one read at 5m).
-3. **The read.** `release.GetIfNoneMatch(ctx, store, apppolicy.Key(acct, app), cachedETag)`: `ErrNotModified` → keep the cached record, stamp the read time; `os.ErrNotExist` → no policy (`EgressPolicy{}`), cache "absent"; `release.ErrRefused` or `os.ErrPermission` → `Closed: EgressUnreadable` (a refusal is never absence, spec §4.3); other error → `Closed: EgressExpired` if the cached policy has expired, else error (the asserter keeps the last policy, as today, so a store outage closes nothing until the record it last verified expires).
-4. **Verify.** `apppolicy.VerifyKey(pub, wire, now(), acct, app)`: `ErrExpired` → `Closed: EgressExpired`; any other error → `Closed: EgressUnverifiable` with the reason in `Detail`. Nothing unverified is cached as a policy.
-5. **Incarnation.** On a fresh read (and on the first read of an app), `ReadCanonicalAdmission(ctx, store, acct, app)`: `os.ErrNotExist` or `hostops.ErrIdentity` → `Closed: EgressOtherIncarnation`; other error → error. `policy.AppID == "" || policy.AppID != adm.Value.AppID` → `Closed: EgressOtherIncarnation`. The sweep reuses the admission's `app_id` from the last fresh read: a purge and recreate means new claims and new starts, which are fresh.
-6. **Revision floor** (after step 5, so a policy for another incarnation can never move it). Read `<sqid>.<app>.json` from `floors`: absent → none. A floor for the same `app_id` with `Revision` above the policy's → `Closed: EgressReplayed`, `Detail` `revision <n> is below <floor>` (an older signed record the agent put back). Otherwise, if there is no floor, or it names another `app_id` (a new incarnation starts its own floor), or the policy's revision is higher, write `{policy.AppID, policy.Revision}` **before** returning the allow. A floor read error other than not-exist, or a write error → `Closed: EgressUnreadable`, `Detail` naming the floor (root's own state failing is not a store blip, so it closes rather than keeping the last allow).
-7. **Expiry between reads.** Whenever the source answers from its cache, `cached.Expired(now())` → `Closed: EgressExpired`. A console down for longer than `apppolicy.Lifetime` therefore closes the address on every box within 30 s of the expiry, with no store read (spec §3, the door keys' trade).
-8. Otherwise `Allow: policy.EgressAllow`. Nothing the agent wrote is read: no registry column, no file under `/data`.
+Rules (spec §0 ruling 9; §3 and §4.3 passages marked deferred are not built):
+- `RunInstanceControl`'s claims branch sets `r.Egress = claimsEgressAsserter(registryEgressHosts(dataDir()))` on the `ClaimRuntimeResources` it already builds, before `withClaimRuntimeResources` and so before `ensureProductionClaimAssembly`, `NewProductionRootClaimStop`, `WithProductionClaimLaunch` and `activateClaimRoot` (the order Astra plan R1-5 asked for; the source is constant from construction, so there is no placeholder and no `SetSource`). It passes the same asserter to `RunWithClaim`, which runs its `Loop`. The registry is read only to name slices; its `egress_allow` column is never read under claims.
+- Every assert's desired list is empty. A slice holding an allow (a box switched to claims with allows granted before) is reset at its first start path, sweep or `POST /egress`; a slice holding nothing is never written, so a box with no policy is byte-identical. Every report is `State: under_claims`, `Detail: claimsEgressDetail`; Task 11's probe publishes it only for hosts whose row carries an allow.
+- Root under claims reads no store for egress and verifies nothing. The claim-mode source (root reading and verifying the signed policy, a revision floor, an authenticated incarnation, durable verified state across restarts) is the follow-up thread ruling 9 names. This plan leaves no seam for it beyond `EgressSource`.
-The floor lives in root's own state directory, beside the moves and seeds floors (`rootmovesfloor.go:51`, `rootseedsfloor.go:16`): `rootstate.Open(appPolicyFloorDir(release.OptDir()), true, false)`, so `/opt/carlos/system/guard/app-policy/<sqid>.<app>.json`, root-owned and `0600`, written atomically by `rootstate`. It survives a control-service restart and a reboot; nothing removes it (a purged app's file is superseded by the next incarnation's `app_id`). An empty `app_id` never reaches step 6: step 5 closes it.
-
-Conditional reads: `S3Store.GetIfNoneMatch` sets `IfNoneMatch` on `GetObjectInput` and maps S3's 304 to `ErrNotModified`; `DirStore` compares its own ETag. A 304 is still a billed request, but it transfers nothing and decodes nothing.
-
-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`:
- - `TestGetIfNoneMatchDirStore`: unchanged object with its etag → `ErrNotModified`; changed → body and new etag; `""` → plain Get; absent → `os.ErrNotExist`.
- - `TestGetIfNoneMatchFallsBackForAPlainStore`: a `Store` wrapper hiding `ConditionalGetter` → same answers through `Get`.
- - `TestS3StoreIsAConditionalGetter`: `var _ release.ConditionalGetter = (*release.S3Store)(nil)` and the same for `*DirStore`.
+- [ ] **Step 1: Write the failing tests**
`egress_source_test.go`:
- - `TestStoreEgressSourceReadsTheVerifiedApp` (policy at `apppolicy.Key(acct, app)` with matching `app_id`; also asserts `acct` from a record equals the sqid plan 1's door writes under, using the console's key for the same account).
- - `TestStoreEgressSourceRefusesWhatDoesNotVerify` (table → `Closed`): an unsigned JSON at the key → `EgressUnverifiable`; signed by another key → `EgressUnverifiable`; the right key's record for app `other` copied to this app's key → `EgressUnverifiable`; signed `Lifetime + time.Minute` ago → `EgressExpired`.
- - `TestStoreEgressSourceRefusesAReplayedRecord`: write rev 1 (allow `10.0.5.20`), read fresh (allowed, floor 1); write rev 2 (no allow), read fresh (floor 2); put rev 1's bytes back at the key; read fresh → `Closed: EgressReplayed` and the floor still 2.
- - `TestStoreEgressFloorSurvivesARestart`: source A accepts rev 3; a new source B over the same `floors` (as after a control-service restart) given rev 2's bytes → `EgressReplayed`; given rev 4 → allowed and the floor file says 4.
- - `TestStoreEgressFloorFollowsTheIncarnation`: floor `{app_id: "a1", revision: 9}`; admission now `a2`, policy `app_id: a2` at rev 1 → allowed, floor `{a2, 1}`; a rev 9 policy with `app_id: a1` → `EgressOtherIncarnation`, floor unchanged.
- - `TestStoreEgressFloorFailureCloses`: `floors.Write` fails → `Closed: EgressUnreadable`.
- - `TestStoreEgressSourceOtherIncarnation` (table: mismatched, empty, admission absent, admission `ErrIdentity`).
- - `TestStoreEgressSourceReadsEachAppAtMostEvery30s`: two hosts of one app and one of another, the clock advanced 10 s per sweep (`fresh == false`): a counting store sees one conditional GET per app per 30 s of clock, none for the app whose object is absent, and `GetIfNoneMatch` carries the cached etag; `fresh == true` reads at once.
- - `TestStoreEgressSourceExpiresFromTheCache`: a cached verified policy and the clock past `SignedAt + Lifetime`: a sweep within 30 s of the last read → `Closed: EgressExpired` with no store call; with the store then failing every read → still `EgressExpired`, never an error that would keep the allow.
- - `TestStoreEgressSourceRefusalIsNotAbsence`: a store whose `Get` returns a refusal for the policy key → `Closed: EgressUnreadable`, never `EgressPolicy{}`. Run it also against plan 1's fleet-door client fixture (`internal/fleetapi`) answering the door's refusal; if that returns `os.ErrNotExist`, stop and report: plan 1's transport is not done.
- - `TestStoreEgressSourceTransientErrorKeepsLast` (store `Get` fails with a network error → error returned).
- - `TestStoreEgressSourceIgnoresTheRegistry` (registry row carries a different `EgressAllow`; result is the store's).
+ - `TestClaimsEgressSourceClosesEveryHost`: any host → `Closed: under_claims`, `Detail` exactly `claimsEgressDetail`; called 100 times with a counting store and registry beside it, neither is touched (the source takes neither).
`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)`.
+ - `TestClaimEgressIsInTheRuntimeResourcesBeforeTheAssembly`: the claims branch's construction up to `bindProductionClaimUnits`, with an assembly stub that records `ClaimRuntimeResources.Egress` when it is opened: non-nil, and the same pointer `bindProductionClaimUnits` puts in `ClaimUnits.Egress` and the branch hands to `RunWithClaim`.
+ - `TestClaimEgressIsClosedThroughTheClaimWiring` (Review Focus 2; production wiring with a fake `Systemctl` only): a registry row for `shop-a.example.com` carrying `EgressAllow: "10.0.5.20"` and a `PolicyExpiresAt` an hour ahead, a signed policy object granting `10.0.5.20` in the claims store (wrapped in a counting store), the slice showing `10.0.5.20/32`. `ClaimUnits.Start` for the host through the bound units: the only egress write is the reset `set-property --runtime <slice> IPAddressAllow=`, before `systemctl start`; `GET /egress` through `RunWithClaim`'s handler reports `under_claims` with `claimsEgressDetail`; the counting store saw no request under `control/app-policy/`. A second start with the slice now empty makes no `set-property` for egress.
+- [ ] **Step 2: Run** `go test ./internal/host -run 'TestClaimsEgressSource|TestClaimEgress' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Implement.** Comment on `claimsEgressSource`: ruling 9, why nothing is read (the agent is treated as compromised under claims, and the store-reading source is deferred), and that this reset is what closes allows granted before claims were switched on.
+- [ ] **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 && GOOS=windows go build ./internal/host`.
+- [ ] **Step 5: Commit** `host: under claims root writes no egress allow and says so (#328)`.
---
@@ -408,13 +353,20 @@ type AddrInventory struct {
Unreadable []string // keys that could not be read or parsed
Missing []string // boxes whose file has no addrs (absent or null: an older box, or a failed read)
}
-func ReadAddrs(ctx context.Context, s release.Store) (AddrInventory, error) // error: the listing failed
+func ReadAddrs(ctx context.Context, s release.Store) (AddrInventory, error) // error: the listing failed (the grant check, Task 9)
func (inv AddrInventory) Holder(a netip.Addr) (box string, ok bool)
+// AddrCache keeps each edges file's ETag and parsed addrs between reads.
+type AddrCache struct { /* unexported: etag and entry per key */ }
+// ReadAddrsCached is ReadAddrs for the report (Task 10, spec §3a): one
+// ListETags of control/edges/, a Get only for a key that is new or whose ETag
+// changed, cached entries dropped for keys no longer listed. A store that is
+// not a release.ETagLister falls back to ReadAddrs, reading every file.
+func ReadAddrsCached(ctx context.Context, s release.Store, c *AddrCache) (AddrInventory, error)
```
-`addrs` is in `f` before `gateCopy` is marshalled, so an address change rewrites the file on the next due pass. Bucketless fleet boxes (`edgesFileLoop` skips them, `edgesfile.go:802-807`) publish nothing; Task 9 names them. Plan 3's `mem_total_mib` field goes beside this one in the gate.
+`addrs` is in `f` before `gateCopy` is marshalled, so an address change rewrites the file on the next due pass, and an unchanged one writes nothing (no added request: spec §3a). Bucketless fleet boxes (`edgesFileLoop` skips them, `edgesfile.go:802-807`) publish nothing; Task 9 names them. Plan 3's `mem_total_mib` field goes beside this one in the gate.
-- [ ] **Step 1: Failing tests:** `TestEdgesFileAddrsInTheGate` (same routes, different `boxAddrs` → a second write); `TestEdgesFileAddrsEmptyAndUnreadable` (Review Focus 5); `TestBoxAddrsDropsLoopback` (fib_trie fixture from `hardening_test.go:228-292`'s style: `127.0.0.0/8`, `127.0.0.1/32`, `10.0.1.5/32`, `::1/128`, `fe80::1/128` → `["10.0.1.5", "fe80::1"]`); `TestReadAddrsInventory` (one file with addrs, one without, one null, one unreadable, one unversioned); `TestReadAddrsHolderMatchesPrefixes`.
+- [ ] **Step 1: Failing tests:** `TestEdgesFileAddrsInTheGate` (same routes, different `boxAddrs` → a second write); `TestEdgesFileAddrsEmptyAndUnreadable` (Review Focus 5); `TestBoxAddrsDropsLoopback` (fib_trie fixture from `hardening_test.go:228-292`'s style: `127.0.0.0/8`, `127.0.0.1/32`, `10.0.1.5/32`, `::1/128`, `fe80::1/128` → `["10.0.1.5", "fe80::1"]`); `TestReadAddrsInventory` (one file with addrs, one without, one null, one unreadable, one unversioned); `TestReadAddrsHolderMatchesPrefixes`; `TestReadAddrsCachedGetsOnlyChangedFiles` (three files; a counting store forwarding `ETagLister`: first read 1 ListETags + 3 Gets; unchanged 1 ListETags + 0 Gets; one file rewritten 1 + 1; one deleted, its box gone from the inventory; a store hiding `ETagLister` reads all three every time); and the box-side budget, `TestEdgesFileAddrsAddNoRequests` (a counting store under the edges write pass: 288 passes with constant routes and constant `boxAddrs` make exactly the requests the same passes make with the `Addrs` field left nil-and-unchanged, so the field adds none; a changed address adds exactly one Put).
- [ ] **Step 2: Run** `go test ./internal/edgesfile -run 'TestReadAddrs' -count=1 && go test ./internal/host -run 'TestEdgesFileAddrs|TestBoxAddrs' -count=1`. Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** Step 2's commands and `go test ./internal/host -run 'TestEdgesFile' -count=1`. Expected: PASS.
@@ -449,32 +401,49 @@ func (s *Server) egressGrantCheck(ctx context.Context, added []netip.Addr) (notC
---
-### Task 10: The console's report (timer, edges change, alert, `!`)
+### Task 10: The console's report (edges generation, backstop, alert, `!`)
**Files:**
-- Modify: `internal/console/egressfleet.go`, `egressfleet_test.go`, `claim_workers.go:5-33` (register `StartEgressReport`), `claim_invariant_fixtures_test.go` (its fixture: `claimNonApp("operator alert mail; writes no store object")`), `cmd/console/main.go:497-525` (`go srv.RunClaimWorker(context.Background(), "StartEgressReport")`)
+- Modify: `internal/console/egressfleet.go`, `egressfleet_test.go`, `claim_workers.go:5-33` (register `StartEgressReport`), `claim_invariant_fixtures_test.go` (its fixture: `claimNonApp("operator alert mail; writes no store object")`), `cmd/console/main.go:497-635` (`go srv.RunClaimWorker(context.Background(), "StartEgressReport")`)
+- Modify: `internal/provision/recordview.go` (`func (v *RecordView) Watch() *release.ChangeWatch`, nil-safe: the watch the view was built over)
**Interfaces:**
-- Consumes: `apppolicy.Prefix`, `apppolicy.Get` (plan 1); `edgesfile.ReadAddrs`; `alerting.Page`, `alerting.LevelWarning`; the `noCapacityAlerts` due/failed pattern (`nocapacity.go:59-95`).
+- Consumes: plan 1's `s.store.AppPolicyRowsContext` and `s.policyHeld()`, `apppolicy.OpenKey`; `edgesfile.ReadAddrsCached`, `AddrCache`, `AddrInventory.Holder` (Task 8); `s.recordView` (`server.go:1185`, set by `SetRecordView` when `CARLOS_CHANGE_INDEX=read`) and its `Watch()`; `release.ChangeIndexBackstop`; `alerting.Page`, `alerting.LevelWarning`; the `noCapacityAlerts` due/failed pattern (`nocapacity.go:59-95`).
- Produces:
```go
func (s *Server) StartEgressReport(ctx context.Context)
+func (s *Server) egressReportDue(now time.Time) bool // reads memory only: the lease state, the watch's last observation, the backstop deadline
func (s *Server) egressReportPass(ctx context.Context) error
func (s *Server) egressConflicts() map[string]string // address -> box, from the last pass; nil before one
var pageEgressConflict = alerting.Page
-const egressReportEvery = 15 * time.Minute
-const egressEdgesPoll = time.Minute
+const egressReportTick = 30 * time.Second // an in-memory check; no store request
const egressAlertEvery = 24 * time.Hour
```
-`StartEgressReport` returns at once when `s.cfg.Releases` is nil. It runs a pass at start and every 15 minutes. Between passes, and only while the last pass found at least one policy with a non-empty allow, it lists `control/edges/` with `ListMod` (fallback `List`) every minute and runs a pass when the key and modified-time set changes (decision: no listing at all on a deployment with no allows). A pass lists `apppolicy.Prefix`, reads each policy, and for each allowed address that `Holder` finds raises one alert per (account, app, address) per `egressAlertEvery` per console process, slot given back on a failed send. The allow stays in force. Draft alert copy (Task 17): subject `Egress allow <addr> is now a fleet box's address`; body `App <app> in account <account> may reach <addr>, and box <box> now holds that address. The allow is still in force. Remove it with carlos apps set <app> --egress-allow, or move the address off the box.`
+Rules (ruling 11, spec §3a and §4.2):
+- `StartEgressReport` returns at once when `s.cfg.Releases` is nil. Every `egressReportTick` it calls `egressReportDue`, which makes no request: a pass is due only while this console is the policy writer (`s.policyHeld()`; a console that is not the writer has nothing current to report), at least one row of `AppPolicyRowsContext` carries a non-empty allow (read from the console's own database, decoded with `OpenKey`; no bucket read), and one of: no pass has run in this holding; `watch.Gen("control/edges")` differs from the generation marked at the last pass (the watch is the record view's, kept current by the run sweep's `Refresh`; the report never polls it itself); or `release.ChangeIndexBackstop + rand.N(release.ChangeIndexBackstop/10)` has passed since the last pass (30 to 33 minutes, at most 48 a day). A nil or unusable watch leaves only the first pass and the backstop. The 15-minute timer of earlier revisions is gone.
+- A pass marks the edges generation before it reads, then `ReadAddrsCached(ctx, s.cfg.Releases, &s.egressAddrs)` (one `ListETags`, a `Get` only for a changed file), then checks every live allow against `Holder`. For each allowed address a box holds it raises one alert per (account, app, address) per `egressAlertEvery` per console process, slot given back on a failed send. The allow stays in force. `egressConflicts()` is replaced whole at the end of a pass.
+- Draft alert copy (Task 17): subject `Egress allow <addr> is now a fleet box's address`; body `App <app> in account <account> may reach <addr>, and box <box> now holds that address. The allow is still in force. Remove it with carlos apps set <app> --egress-allow, or move the address off the box.`
-- [ ] **Step 1: Failing tests:** `TestEgressReportAlertsOnceAndKeepsTheAllow` (an address that a later edges file lists: one page across two passes, policy object unchanged, `egressConflicts()` names it), `TestEgressReportRetriesAFailedSend`, `TestEgressReportPollsOnlyWithAllows` (fake store counts `List` calls: zero edges listings between passes with no allows), `TestEgressReportRunsOnEdgesChange`.
-- [ ] **Step 2: Run** `go test ./internal/console -run 'TestEgressReport|TestClaimConsoleMutationInvariant' -count=1`. Expected: FAIL.
-- [ ] **Step 3: Implement.**
+- [ ] **Step 1: Failing tests** (`t.Parallel()`; the fixture's policy rows written through plan 1's door; a `countingStore` over the fixture's `Releases`, forwarding `ETagLister` and `ConditionalGetter`, counting `List`/`ListETags` and `Get` under `control/edges/` and under `control/app-policy/`; a fake clock driving `egressReportDue` and the pass directly): `TestEgressReportAlertsOnceAndKeepsTheAllow` (an address that a later edges file lists: one page across two passes, policy object unchanged, `egressConflicts()` names it), `TestEgressReportRetriesAFailedSend`, `TestEgressReportRunsOnEdgesGeneration` (a write under `control/edges/` on the index-enabled store, the watch polled: the next tick runs a pass; with the generation unchanged it does not), `TestEgressReportNeedsTheWriterLease` (Task 8's fake leaser refusing: no pass, no request), `TestEgressReportIsQuietWithNoAllows` (rows with no allow: no pass, no request, all day), and the budget:
+
+```go
+// The §3a console budget for the report, pinned (ruling 11). Fails on any increase.
+func TestEgressReportKeepsTheRequestBudget(t *testing.T) {
+ // three boxes' edges files, two live allows; the first pass, then reset the counters;
+ // 2880 ticks 30 s apart (one day) with nothing written:
+ // ListETags under control/edges/ <= 48, Get under control/edges/ == 0,
+ // requests under control/app-policy/ == 0, List == 0
+ // one edges file rewritten (bumps control/edges): the next tick runs a pass with exactly
+ // 1 ListETags and 1 Get
+}
+```
+
+- [ ] **Step 2: Run** `go test ./internal/console -run 'TestEgressReport|TestClaimConsoleMutationInvariant' -count=1 && go test ./internal/provision -run 'TestRecordView' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Implement.** Comment on `StartEgressReport`: ruling 11 (no timer that reads the store; the tick reads memory), why only the writer reports, why the allows come from the console's table and not the bucket.
- [ ] **Step 4: Run** Step 2's command. Expected: PASS.
-- [ ] **Step 5: Commit** `console: re-check egress allows against fleet addresses and alert (#328)`.
+- [ ] **Step 5: Commit** `console: re-check egress allows on an edges change and the backstop, and alert (#328)`.
---
@@ -502,9 +471,9 @@ func egressStatusProbe(c interface{ EgressStatus(context.Context) (instancectl.E
rows []registry.Route, agentStates map[string]string) map[string]status.HostEgress
```
-Before claims the agent is the one that verifies the record (plan 1), so root only ever sees an emptied column. `agentStates` is `provision.PolicyReadStates()`: for each instance row whose `<account>/<app>` has an entry and whose root report has no `State`, the probe sets `State` to `unreadable`, `unverifiable` or `expired` (the root words, from plan 1's `policy unreadable` / `policy unverifiable` / `policy expired`) so the same `!policy expired` reaches the display either way. Under claims root's own state wins. The probe asks root with a 5 s timeout; an error leaves the field nil (absent, "not reported", never "none"), logged throttled. A box with no `eb.Control` passes nil. Byte-identical: a box with no hosts reported publishes no `egress` key.
+Before claims the agent is the one that verifies the record (plan 1), so root only ever sees an emptied column. `agentStates` is `provision.PolicyReadStates()`: for each instance row whose `<account>/<app>` has an entry and whose root report has no `State`, the probe sets `State` to `unreadable`, `unverifiable` or `expired` (status words mapped from plan 1's `policy unreadable` / `policy unverifiable` / `policy expired`; `unverifiable` is never a root state) so the same `!policy expired` reaches the display either way. The report's `Detail` is copied as root sent it (Task 2, Astra plan R2-4). **Under claims** (ruling 9) root reports `under_claims` for every slice it asserts; the probe publishes that entry only for a host whose row carries a non-empty `EgressAllow`, so an operator who granted an allow sees `egress: not yet supported under claims` and a box with no policy publishes nothing new. The probe asks root with a 5 s timeout; an error leaves the field nil (absent, "not reported", never "none"), logged throttled. A box with no `eb.Control` passes nil. Byte-identical: a box with no hosts reported publishes no `egress` key.
-- [ ] **Step 1: Failing tests:** `TestCollectBoxCarriesEgress`, `TestCollectBoxOmitsEmptyEgress`, `TestEgressStatusProbeConvertsAndFailsQuiet`, `TestEgressStatusProbeCarriesTheAgentsPolicyState` (row `ab12/shop`, `agentStates{"ab12/shop": "policy expired"}`, root reports `{}` → `State: "expired"`; root reports `failing` → `failing` kept).
+- [ ] **Step 1: Failing tests:** `TestCollectBoxCarriesEgress`, `TestCollectBoxOmitsEmptyEgress`, `TestEgressStatusProbeConvertsAndFailsQuiet`, `TestEgressStatusProbeCarriesTheAgentsPolicyState` (row `ab12/shop`, `agentStates{"ab12/shop": "policy expired"}`, root reports `{}` → `State: "expired"`; root reports `failing` → `failing` kept), `TestEgressStatusProbeCarriesDetail` (root's `Detail` reaches `HostEgress.Detail` unchanged), `TestEgressStatusProbeShowsUnderClaimsOnlyWhereGranted` (root reports `under_claims` for two hosts; the row of one carries `EgressAllow: "10.0.5.20"`: only that host is published, with `State: "under_claims"` and `Detail: "not yet supported under claims"`; with neither row carrying an allow the map is empty and `CollectBox` publishes no `egress` key).
- [ ] **Step 2: Run** `go test ./internal/status -run 'TestCollectBox.*Egress' -count=1 && go test ./internal/host -run 'TestEgressStatusProbe' -count=1`. Expected: FAIL.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** Step 2's commands. Expected: PASS.
@@ -535,7 +504,7 @@ type apiEgress struct {
func egressCell(e *apiEgress) string // root package
```
-`egressCell`: nil → `-`; addresses in `Allowed` comma-joined, each in `Conflicts` prefixed `!`; nothing allowed → `-`; then, when `State` is set, ` !<word>` with the word from Task 17's table (`failing`, `policy unreadable`, `policy unverifiable`, `policy expired`, `policy replayed`, `policy for another incarnation`, `box not wholly nested`); when only `Refused` is set, ` !refused`. `status --layout`'s heading becomes `BOX HOST LAYOUT EGRESS REASON`; its REASON cell appends `egress: <refused or detail>` when present. `instances list` gains `EGRESS` after `DB`. `apps show` prefixes `!` on configured addresses in `egressConflicts()` (plan 1's GET reply gains `egress_conflicts`, a list of addresses). `apps set --egress-allow` prints, after `appsSetEgressDone`, the draft `appsSetEgressNotChecked` = `Not checked against these boxes, which publish no address list: %s.` with the reply's `not_checked` comma-joined, only when it is non-empty. An old console's answer (no `egress`) shows `-`.
+`egressCell`: nil → `-`; addresses in `Allowed` comma-joined, each in `Conflicts` prefixed `!`; nothing allowed → `-`; then, when `State` is set, ` !<word>` with the word from Task 17's table (`failing`, `policy unreadable`, `policy unverifiable`, `policy expired`, `box not wholly nested`, `not yet supported under claims`); when only `Refused` is set, ` !refused`. `status --layout`'s heading becomes `BOX HOST LAYOUT EGRESS REASON`; its REASON cell appends `egress: <refused or detail>` when present. `instances list` gains `EGRESS` after `DB`. `apps show` prefixes `!` on configured addresses in `egressConflicts()` (plan 1's GET reply gains `egress_conflicts`, a list of addresses). `apps set --egress-allow` prints, after `appsSetEgressDone`, the draft `appsSetEgressNotChecked` = `Not checked against these boxes, which publish no address list: %s.` with the reply's `not_checked` comma-joined, only when it is non-empty. An old console's answer (no `egress`) shows `-`.
- [ ] **Step 1: Failing tests:** `TestEgressCellTable` (nil, none, two allowed, one conflict, each state, refused only); `TestStatusLayoutShowsEgress` (heading and a row); `TestInstancesListShowsEgress`; `TestAPIStatusLayoutCarriesEgressAndConflicts`; `TestAPIInstancesJoinsEgress` (stale box ignored, as `dbBytes`); `TestAppsShowMarksConflicts`; `TestAppsSetPrintsNotChecked` (reply `not_checked: ["box-c"]` → the line; `[]` → no line).
- [ ] **Step 2: Run** `go test . -run 'TestEgressCell|TestStatusLayout|TestInstancesListShowsEgress|TestAppsShowMarksConflicts|TestAppsSetPrintsNotChecked' -count=1 && go test ./internal/console -run 'TestAPIStatusLayoutCarriesEgress|TestAPIInstancesJoinsEgress' -count=1`. Expected: FAIL.
@@ -562,10 +531,10 @@ func egressCell(e *apiEgress) string // root package
- [ ] **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:")`, 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 4: Add `TestPlan14EgressClaimLaunchRootRunner`** (ruling 9's witness through the real claim launch: `plan14InstalledClaimLaunch(t, "egress:")`, with `egressTargetNetns(t)`): before the launch, the host's slice is given `IPAddressAllow=10.0.5.20/32` by hand (an allow granted before claims were switched on), the registry row carries `EgressAllow: "10.0.5.20"` with an unexpired `PolicyExpiresAt`, and the harness's store holds a policy signed with its release key granting `10.0.5.20`. The control service runs with `claimsEgressAsserter` (Task 6). The claim launch through `ClaimUnits.StartReserved` (and so `claimEffectiveUnit`) succeeds; `systemctl show -p IPAddressAllow --value <slice>` is empty; the tenant's `?dial=10.0.5.20:<port>` answers 403; `GET /egress` reports `under_claims` with `claimsEgressDetail`; `carlos status --layout` would show `egress: not yet supported under claims` in REASON; nothing exists under `/opt/carlos/system/guard/app-policy/`.
- [ ] **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)`.
+- [ ] **Step 7: Commit** `host: production-wired egress witnesses for every start path, a live revoke and the claim launch (#328)`.
---
@@ -593,16 +562,20 @@ func egressCell(e *apiEgress) string // root package
---
-### Task 16: Census overlay
+### Task 16: The census, as an approval (#369), with the new root reads as decisions
+
+The flow is AGENTS.md "The census: an approval, not a decision". `NAME` is the branch this plan is built on (`instance-policy-2-egress` unless built elsewhere); the overlay joins `internal/provision/testdata/plan14-root-reads-chain.tsv` after plan 1's, so it loads after `ci-speed-activator`, `securelisten-337` and every chained overlay. `plan14_root_reads_test.go` is not edited by hand.
**Files:**
-- Create: `internal/provision/testdata/plan14-root-reads-instance-egress.tsv`, `docs/superpowers/notes/census-proposal-instance-egress.md`
-- Modify: `internal/provision/plan14_root_reads_test.go` (`TestPlan14RootReadCensus`, after the last overlay load at ~1100: load this one last, after `plan14-root-reads-ci-speed-activator.tsv` or after plan 1's overlay if plan 1 added one, checking the previous seal first)
+- Scratch (not committed): `$TMPDIR/census-claims-instance-policy-2.tsv`
+- Created by the tool, or by hand for the decision rows: `internal/provision/testdata/plan14-root-reads-instance-policy-2-egress.tsv`, its chain line, `docs/superpowers/notes/census-proposal-instance-policy-2-egress.md`
-- [ ] **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` (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.
+- [ ] **Step 1: Read the card.** `make census-card`. Expected: `body`/`sites` for the five start-path changes, `ClaimUnits.start`, `claimstop_run.go`'s branches and `bindProductionClaimUnits`; `envelope` for `internal/instancectl`, `internal/host`, `internal/edgesfile`, `internal/status`, `internal/provision` (`RecordView.Watch`); and **decision classes** (`new` or `reads`) for root's new units: `EgressAsserter.Assert`, `Sweep`, `Loop`, `populatedHostSlices` (a cgroupfs read), `Server.handleEgress*`, `registryEgressSource` (root reading the agent-written registry, including `policy_expires_at`), `registryEgressHosts`, `boxWhollyNested` (the registry and `layout.JournalPresent`, pre-claim only), `claimsEgressSource` and `claimsEgressAsserter` (no read at all). Nothing under claims reads the store or writes root state for egress (ruling 9).
+- [ ] **Step 2: Write the CLAIMS file** for every approval-class unit (`<file>:<unit><TAB><what changed; no read added, or which>`), and **write the decision rows by hand** in the chained overlays' shape: each new root unit with its executing identity (`carlos-instance-control`), its proposed disposition against main's siblings (`instanceRegistryHostApp`, `allowResolvers`) and the trust argument of spec §4.3 for each registry read (before claims the agent already decides `HostApp`; under claims nothing agent-written is read). The agent's units (user `carlos`): `egressNudgeLoop`, `boxAddrs` (procfs), `egressStatusProbe`.
+- [ ] **Step 3: Propose.** `make census-propose NAME=instance-policy-2-egress CLAIMS=$TMPDIR/census-claims-instance-policy-2.tsv`. It refuses while a decision row is unwritten; once the hand rows are in, it writes the rest, the seal and the card, and checks the census is clean. Rows carry `rev3=census-ruling-instance-policy-2-egress`.
+- [ ] **Step 4: Ask.** The card goes to Paul in the session (approve or hold) for the approval rows; the decision rows go to #280 with the card attached, for the census owner's ruling. Record both on #280: who, when, the tip. Merge waits for them.
+- [ ] **Step 5: Run** `go test ./internal/provision -run '^TestPlan14RootReadCensus$' -count=1`. Expected: PASS.
+- [ ] **Step 6: Commit** the overlay, chain line and card: `census: instance-policy-2 egress, for approval and ruling (#280, #369)`. Rerun Step 3 with the same `NAME` after any later code change. Delete the scratch CLAIMS file.
---
@@ -611,7 +584,7 @@ func egressCell(e *apiEgress) string // root package
**Files:**
- Modify: wherever Tasks 9, 10 and 12 put the drafts; `approved_copy_test.go` (or the matching `approved_*_copy_test.go`) and `status_instances_test.go:67` to pin the approved text
-- [ ] **Step 1: Collect the strings** with their file and line: the `EGRESS` heading and the new `status --layout` heading; the seven state words (`failing`, `policy unreadable`, `policy unverifiable`, `policy expired`, `policy replayed`, `policy for another incarnation`, `box not wholly nested`) and `refused` (and `pending` if Task 1 required it); `egress: ` REASON prefix; the four grant refusals and `appsSetEgressNotChecked` (Task 12); the alert subject and body; root's log lines that an operator reads in `carlos logs` (`no host for this slice; its allow is closed`, the failing line). Leave out JSON keys and internal error fragments.
+- [ ] **Step 1: Collect the strings** with their file and line: the `EGRESS` heading and the new `status --layout` heading; the six state words (`failing`, `policy unreadable`, `policy unverifiable`, `policy expired`, `box not wholly nested`, `not yet supported under claims`), the under-claims detail `not yet supported under claims`, shown as `egress: not yet supported under claims` (Task 6; ruling 9 fixes its sense, the copy review its words) and `refused` (and `pending` if Task 1 required it); `egress: ` REASON prefix; the four grant refusals and `appsSetEgressNotChecked` (Task 12); the alert subject and body; root's log lines that an operator reads in `carlos logs` (`no host for this slice; its allow is closed`, the failing line). Leave out JSON keys and internal error fragments.
- [ ] **Step 2: Invoke the `copy-review` skill** with that list and wait for Paul's edits.
- [ ] **Step 3: Apply the approved text verbatim**, then count each changed string's occurrences at the base commit before replacing (shared strings need their own review).
- [ ] **Step 4: Run** `go test . -run 'TestApprovedCopy|TestStatusLayout|TestInstancesListShowsEgress|TestEgressCell' -count=1 && go test ./internal/console -run 'TestEgressGrant|TestEgressReport' -count=1`. Expected: PASS.
@@ -621,8 +594,9 @@ func egressCell(e *apiEgress) string // root package
## Self-review notes
-- Spec coverage: §4.1 (Task 2, root side; door side is plan 1), §4.2 (Tasks 8, 9, 10, root's own-address drop in Task 2), §4.3 sources (Tasks 5, 6), write and when (Tasks 2, 3, 4, 7), kernel proof (Task 1), §4.4 (Tasks 11, 12), §3 `app_id` (Task 6), §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.
+- Spec coverage (rev 9): §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 before claims (Task 5), ruling 9 under claims (Task 6, witnessed in Task 13 Step 4), write and when (Tasks 2, 3, 4, 7), kernel proof (Task 1), §4.4 (Tasks 11, 12), §4.3 rev 8 sidecar members (Task 5), §3a (Tasks 8 and 10, with counting-store tests; root and the agent read no store), §7 byte-identical (Task 15), §11 egress items (Tasks 1, 2, 9, 10, 13, 14, 15). Not built, by ruling 9: §3's and §4.3's deferred passages (root's store read, signature check in root, revision floor, `app_id` binding, the 30 s and 5-minute reads); §11's "fleet box receives the policy through the store door" is plan 1's agent path (Task 5 there). Sizes are plan 3.
+- Not this plan's: the policy object, door, CLI `apps set`, purge sweep, store-door read grant, the writer lease and the agent's column convergence (plan 1).
+- Re-run after spec rev 9 and Astra plan R2: Names: `EgressSource` is `func(ctx, host) (EgressPolicy, error)` in Tasks 2, 4, 5 and 6, with no `fresh`; `EgressPolicy.Detail` is defined in Task 2 and used in Tasks 5 and 6; the root states are `failing`, `unreadable`, `expired`, `not_nested`, `under_claims` in Tasks 2, 6, 11, 12 and 17; `claimsEgressSource`/`claimsEgressAsserter` in Tasks 6 and 13 only; `ReadAddrsCached` and `AddrCache` in Tasks 8 and 10; no `newStoreEgressSource`, `appPolicyFloor`, `withClaimEgress`, `SetSource`, `GetIfNoneMatch` or `claimPolicyEvery` remains (the dropped `release.ConditionalGetter` would also have collided with the one main already has, `GetIfChanged`). Proportion: the plan is shorter than before; Task 6 lost its store source and Task 10 its timers. Placeholders: none. Copy: one new detail string and one new state word, both in Task 17.
- 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)
@@ -634,3 +608,14 @@ func egressCell(e *apiEgress) string // root package
| 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. |
+
+## Review dispositions (Astra plan R2 + spec rev 9)
+
+| Finding or ruling | What changed |
+|---|---|
+| Ruling 9 (claim-mode egress deferred) | Task 6 rewritten: the store read, root's signature check, the revision floor and its state file, the `app_id`/admission binding, the per-app 30 s and 5-minute reads, `release.GetIfNoneMatch`, and all their tests are gone. Under claims root's source is a constant: every assert's desired list is empty, a held allow is reset, nothing is written on an empty slice, and the report says `egress: not yet supported under claims`. Witnesses: `TestClaimEgressIsClosedThroughTheClaimWiring` (the bound claim units, a stale allow on the slice, a policy in the store, no store read), `TestPlan14EgressClaimLaunchRootRunner` (the real claim launch on the root runner). R1-5's construction order is kept (the asserter is in `ClaimRuntimeResources` before the assembly); its placeholder and `SetSource` are gone, because the source is constant. |
+| R2-1 (P1) admission data resets the revision floor | Superseded by ruling 9: no floor, no admission read. |
+| R2-3 (P1) a restart keeps a claim-mode allow past expiry | Superseded by ruling 9: no claim-mode allow exists. Its pre-claim analogue is closed too: a fresh asserter whose source errors now resets the slice instead of keeping what it holds (Task 2 `TestEgressNoCacheAndSourceErrorCloses`), and the pre-claim source closes at the recorded expiry (Task 5). |
+| R2-4 (P1) `EgressPolicy` lacks `Detail` | Task 2: `EgressPolicy.Detail string`; `Assert` copies it into `EgressReport.Detail` when `Closed` is set, and a source error's text when it is not; Task 11 carries it to `HostEgress.Detail`. Tests: `TestEgressClosedPolicyCarriesItsDetail`, `TestEgressStatusProbeCarriesDetail`. |
+| Ruling 11, spec §3a | Task 10: the report runs on a `control/edges` generation change and on the backstop (30 to 33 minutes), only on the policy writer, reading the live allows from the console's table and the edges files through `ReadAddrsCached` (one `ListETags`, GET only changed). The 15-minute timer and the per-minute edges listing are gone. Budget tests: `TestEgressReportKeepsTheRequestBudget` (console: at most 48 LISTs and 0 GETs a day with nothing changing), `TestEdgesFileAddrsAddNoRequests` (box: `addrs` adds no request). |
+| Census (#369) | Task 16 uses the approval flow; the new root units are decision rows written by hand and ruled on #280. |
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 8ade8a1b5..6aaa02f99 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
@@ -8,7 +8,7 @@
**Tech Stack:** Go, systemd (`set-property --runtime`, `show`), cgroup v2, SQLite registry, S3-compatible store.
-**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 8, approved). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`. Read §5 and the §9 dispositions R1-1, R1-2, R1-3, R2-1, R3-1 before simplifying anything.
+**Spec:** `docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md` (rev 9, approved; §0 rulings 9 to 11 and §3a apply; ruling 9 leaves sizes under claims unchanged: a new size waits for the next start). Brief: `docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md`. Read §5 and the §9 dispositions R1-1, R1-2, R1-3, R2-1, R3-1 before simplifying anything.
## Global Constraints
@@ -16,7 +16,8 @@
- Local tests are targeted: only touched packages, with `-run`. Never `make test`, never the whole console or host suite locally; CI runs the full gate (`.amadan/test-gate.sh`).
- Parallel tests: new tests in `internal/host`, `internal/activator`, `internal/console` call `t.Parallel()` unless they set env, cwd, umask, log output or package vars, reach `edge.SecureUnixListen`, or listen on TCP (activator). No test depends on another having run. A new console or root test slower than a few seconds refreshes `.amadan/test-weights.tsv` with `.amadan/test-weights.sh`.
- Cross-compile: CI builds darwin and windows. Linux-only syscalls in `internal/host` or `internal/instancectl` need `//go:build linux` and a `!linux` stub.
-- Root code changes end with a census overlay (Task 13), loaded after `plan14-root-reads-ci-speed-activator` and after plan 2's overlay.
+- Root code changes end with a census task (Task 13) in the #369 approval flow (AGENTS.md "The census: an approval, not a decision"): `make census-card`, a CLAIMS file, `make census-propose NAME=<branch> CLAIMS=<file>`, the card shown to Paul. The chain manifest loads the overlay after `ci-speed-activator`, `securelisten-337`, plan 1's and plan 2's.
+- Request budget (spec §3a, ruling 11): sizes add no store request in steady state. `mem_total_mib` rides the edges file's gated write (on change only); the activator's live-size loop reads the local registry, never the store; the console reads boxes and edges files only on a size change. Task 8 pins the box side with a counting-store test that fails on any increase.
- Production-wired witnesses: claim and root behaviour is tested through the real control service, units and wiring, not fixtures or sleeps.
- Privileged tests: `//go:build linux && rootrunner`, run with `~/.cache/tmp/claude-1001/own/after236/root-runner.py --worktree DIR --pkg ./internal/x --run REGEX` with the sandbox off; read `root-runner-contract.md` beside the script.
- User-facing strings go through Task 14 (copy review) before merge. Plain English, no em dashes. Internal error fragments and JSON keys are not copy.
@@ -466,9 +467,15 @@ The field is part of the write gate (`edgesfile.go:659-668`) because only `Headr
func TestEdgesFilePublishesMemTotal(t *testing.T) // ident.MemTotalMiB = 3884 -> the written JSON has "mem_total_mib":3884 at top level, with region HA off (no headroom)
func TestEdgesFileMemTotalIsInTheGate(t *testing.T) // same file, total changes 3884 -> 7820 -> a second write; unchanged -> none
func TestPlacementViewReadsMemTotal(t *testing.T) // parse; absent -> 0
+// The §3a box budget for sizes, pinned (ruling 11). Fails on any increase.
+func TestSizesAddNoBoxStoreRequests(t *testing.T)
+// a counting store under the edges write pass: 288 passes (one day) with constant routes and
+// ident.MemTotalMiB 3884 make exactly the requests the same 288 passes make with MemTotalMiB 0
+// (the field adds none); a changed total adds exactly one Put. (The activator's live-size loop
+// takes no store at all: Task 6's retrySizeLocked reads a.Routes, the local registry.)
```
-- [ ] **Step 2: Run to see them fail**: `go test ./internal/host/ -run 'EdgesFile.*MemTotal' && go test ./internal/edgesfile/ -run PlacementViewReadsMemTotal`. Expected: FAIL.
+- [ ] **Step 2: Run to see them fail**: `go test ./internal/host/ -run 'EdgesFile.*MemTotal|TestSizesAddNoBoxStoreRequests' && go test ./internal/edgesfile/ -run PlacementViewReadsMemTotal`. Expected: FAIL.
- [ ] **Step 3: Implement.**
@@ -484,10 +491,11 @@ func TestPlacementViewReadsMemTotal(t *testing.T) // parse; absent -> 0
- Test: `internal/console/policysize_test.go` (create)
**Interfaces:**
-- Consumes: `PlacementView.MemTotalMiB` (Task 8), `BoxMetrics.SizeCommittedMiB` (Task 7), `readEdgesSnapshot` (`instancesplacement.go:82`), `loadEdgesJoin` (`:48`), `s.homeRegionIn` (`apiinstancemove.go:306`), `s.systemBoxes`.
+- Consumes: `PlacementView.MemTotalMiB` (Task 8), `BoxMetrics.SizeCommittedMiB` (Task 7), `readEdgesSnapshot` (`instancesplacement.go:82`), `status.LoadBoxesComplete` (`internal/status/fleet.go:234`), `status.BoxStale` with `status.SnapshotPeriod`, `registry.ListFleets` and `ListFleetBoxes` (`internal/registry/fleets.go:230,445`) through `s.openPassRegistry()`, `s.homeRegionIn` (`apiinstancemove.go:306`).
- Produces:
- - `func (s *Server) sizeBoxes(snap edgesSnapshot, recs []provision.Record) []edgesFilePlacementView`: a box is in the set when it serves one of the records' hosts, or when `s.homeRegionIn(f.Placement) == s.homeRegionIn(rec.Region)` for one of them. With no records (an app default on an app with no instances yet), every box in the snapshot.
- - `func (s *Server) sizeCapacityCheck(ctx context.Context, recs []provision.Record, size cgroup.Size) error`: refuses (409, the door's refusal shape) when `size.Limit.MemoryMax >> 20 > f.MemTotalMiB / 4` for the smallest-total box, naming it and its total; when any box in the set has `MemTotalMiB == 0` and the size is above standard, naming that box; when the edges listing failed or `!snap.Complete` and the size is above standard. `small` and `standard` are never refused for a missing total.
+ - `type sizeBox struct { Box, Region string; TotalMiB int64 }` (`TotalMiB 0` = unknown)
+ - `func (s *Server) sizeBoxes(ctx context.Context, acct *Account, recs []provision.Record) (boxes []sizeBox, complete bool, err error)`. **The eligible boxes come from the box listings, not from edges files** (Astra plan R2-5): the platform boxes of `status.LoadBoxesComplete(ctx, s.cfg.Releases)` that are not stale, plus the `active` `FleetBox` rows of every fleet whose `Account` is the app's account (fleet boxes never publish an edges file, `edgesfile.go:803`, so their totals are unknown). With records, a platform box counts when `s.homeRegionIn(b.Region) == s.homeRegionIn(rec.Region)` for one of them, or when an edges file lists it serving one of the records' hosts (stale or not); a fleet box of the account always counts. With no records (an app default on an app with no instances yet), every eligible box. **Then the totals are joined:** one `readEdgesSnapshot`, matched on the file's `Box` to the listing's label (the same join plan 2's `not_checked` makes); a box with no file, an unreadable file or no `mem_total_mib` gets `TotalMiB 0`. `complete` is false when `LoadBoxesComplete` says incomplete, the registry or a fleet's roster cannot be read, or the edges listing fails.
+ - `func (s *Server) sizeCapacityCheck(ctx context.Context, acct *Account, recs []provision.Record, size cgroup.Size) error`: for a size above `standard`, refuses (409, the door's refusal shape) when `!complete` (string 3) or when any box in the set has `TotalMiB == 0`, naming the first such box by label order (string 2); for any size, refuses when `size.Limit.MemoryMax >> 20 > TotalMiB / 4` for the smallest known total, naming that box and its total (string 1). `small` and `standard` are never refused for an unknown total. Reads happen only on a size change (spec §3a).
- `type sizeCommittedBox struct { Box string \`json:"box"\`; CommittedMiB int64 \`json:"committed_mib"\`; TotalMiB int64 \`json:"total_mib"\`; FreeMiB int64 \`json:"free_mib"\` }`; `func (s *Server) sizeCommitted(ctx context.Context, boxes []string) []sizeCommittedBox` from each box's status snapshot (`SizeCommittedMiB`, `MemTotalBytes`, `MemAvailBytes`); a box without them is left out. Information, never a gate.
Placement (`boxchoice`) is unchanged.
@@ -500,12 +508,21 @@ func TestSizeCapacityRefusesAboveAQuarterOfTheSmallestBox(t *testing.T)
// large (1024 > 971) refused; standard passes.
func TestSizeCapacityRefusesAboveStandardWhenATotalIsMissing(t *testing.T) // one box with no mem_total_mib: large refused naming it; standard and small pass
func TestSizeCapacityRefusesAboveStandardOnAnIncompleteSnapshot(t *testing.T) // an unreadable edges file: large refused; standard passes
-func TestSizeBoxesUsesPlacementAndServingBoxes(t *testing.T) // a box in another region that serves nothing is not in the set; one serving a host is
+func TestSizeCapacityRefusesForAnEligibleBoxWithNoEdgesFile(t *testing.T)
+// Astra plan R2-5: box-a has a fresh status report in the app's region and no edges file at all;
+// box-b publishes mem_total_mib 65536. large refused naming box-a (string 2); standard and small pass.
+// The same with box-a's status report stale and serving nothing: large passes (box-b, 1024 <= 16384).
+func TestSizeBoxesComeFromTheListingsNotTheEdgesFiles(t *testing.T)
+// an active fleet box of the app's account (no edges file) is in the set with TotalMiB 0; a detached one
+// and another account's fleet box are not; a platform box in another region that serves nothing is not;
+// one serving a host is, even when its status is stale
+func TestSizeCapacityRefusesAboveStandardWhenTheBoxListingIsIncomplete(t *testing.T)
+// LoadBoxesComplete reports incomplete (an unreadable status report): large refused (string 3); standard passes
func TestSizeCommittedReportsEachBox(t *testing.T) // status snapshot with size_committed_mib 2304, total 3884 MiB, avail 1200 MiB -> {box, 2304, 3884, 1200}
func TestPolicyDoorRefusesAnOversizeBeforeTheAuditWrite(t *testing.T) // through plan 1's door: refused change writes no audit event and no policy object
```
-- [ ] **Step 2: Run to see them fail**: `go test ./internal/console/ -run 'SizeCapacity|SizeBoxes|SizeCommitted|PolicyDoorRefusesAnOversize'`. Expected: FAIL.
+- [ ] **Step 2: Run to see them fail**: `go test ./internal/console/ -run 'SizeCapacity|SizeBoxes|SizeCommitted|PolicyDoorRefusesAnOversize'`. Expected: FAIL. (Tests build status reports with `status.Box` JSON under `status/boxes/`, fleets with `reg.CreateFleet` and `AddFleetBox`, and edges files under `control/edges/`, on the fixture's `DirStore`.)
- [ ] **Step 3: Implement.** Refusal texts are Task 14's strings 1-3.
@@ -603,18 +620,20 @@ There is no exception to state: with no operator size every swap is main's (Task
- [ ] **Step 3: Commit** (`sizes: prove an empty size column is byte-identical to main`)
-### Task 13: Census overlay
+### Task 13: The census, as an approval (#369), with the new root reads as decisions
-**Files:**
-- Create: `internal/provision/testdata/plan14-root-reads-instance-sizes.tsv`
-- Create: `docs/superpowers/notes/census-proposal-instance-sizes.md`
-- Modify: `internal/provision/plan14_root_reads_test.go:~1100` (load the overlay after plan 2's, which loads after `ci-speed-activator`; check the previous seal, then pin the new one)
-
-- [ ] **Step 1: Write the proposal and overlay**, in the shape of `census-proposal-moves-offline-fixes.md`. Rows: `instancectl` `handleSize` and `handleSizeReadBack` (new: root, `systemctl show` reads of a slice and a generation unit, a `cgroup.events` read); `parseRequest`, `claimTier`, `sliceTier`/`sliceSize`, `startGen`, `removeGenFiles` (changed: the control drop-in removal is a root file removal under `/run/systemd/system.control`, keyed by a validated unit name); `host` `claimLaunchTier`, `claimIsolatedPaths`, `claimBoundSnapshot` (changed legacy, no new read); `cgroup` `prepareStrict`, `PrepareGen`, `ValidParentLimit`, `SharesOf` (changed legacy, no read); `host` `readSizeInForce` (new: the agent's cgroupfs read, J4). Every row carries `rev3=PROPOSED-instance-sizes`.
+The flow is AGENTS.md "The census: an approval, not a decision". `NAME` is the branch this plan is built on (`instance-policy-3-sizes` unless built elsewhere); the overlay joins `internal/provision/testdata/plan14-root-reads-chain.tsv` after plan 2's, so it loads after `ci-speed-activator`, `securelisten-337` and every chained overlay. `plan14_root_reads_test.go` is not edited by hand.
-- [ ] **Step 2: Run** `go test ./internal/provision/ -run TestPlan14RootReadCensus`. Expected: PASS with the new seal.
+**Files:**
+- Scratch (not committed): `$TMPDIR/census-claims-instance-policy-3.tsv`
+- Created by the tool, or by hand for the decision rows: `internal/provision/testdata/plan14-root-reads-instance-policy-3-sizes.tsv`, its chain line, `docs/superpowers/notes/census-proposal-instance-policy-3-sizes.md`
-- [ ] **Step 3: Commit** (`census: overlay instance-sizes, proposed`). The #280 ruling is asked before merge.
+- [ ] **Step 1: Read the card.** `make census-card`. Expected approval classes (`body`, `sites`, `envelope`): `parseRequest`, `claimTier`, `sliceTier`/`sliceSize`, `claimLaunchTier`, `claimIsolatedPaths`, `claimBoundSnapshot`, `prepareStrict`, `PrepareGen`, `ValidParentLimit`, `SharesOf` (the same reads, or none, in edited bodies) and the re-pinned packages (`internal/cgroup`, `internal/instancectl`, `internal/host`, `internal/activator`, `internal/status`, `internal/edgesfile`). Expected decision classes (`new` or `reads`): `instancectl` `handleSize` and `handleSizeReadBack` (root: `systemctl show` of a slice and a generation unit, a `cgroup.events` read); `startGen` and `removeGenFiles` (a new kind of root action: removing `/run/systemd/system.control/<unit>.d`, keyed by a validated unit name); `host` `readSizeInForce` (the agent's cgroupfs read, J4).
+- [ ] **Step 2: Write the CLAIMS file** for the approval-class units (`<file>:<unit><TAB><what changed; no read added, or which>`), and **write the decision rows by hand** in the chained overlays' shape, each with its executing identity and proposed disposition.
+- [ ] **Step 3: Propose.** `make census-propose NAME=instance-policy-3-sizes CLAIMS=$TMPDIR/census-claims-instance-policy-3.tsv`: the overlay (rows `rev3=census-ruling-instance-policy-3-sizes`), the fingerprints, re-pins, seal, chain line and card, with the census checked clean.
+- [ ] **Step 4: Ask.** The card goes to Paul (approve or hold) for the approval rows; the decision rows go to #280 with the card, for the census owner's ruling. Record both on #280: who, when, the tip. Merge waits for them.
+- [ ] **Step 5: Run** `go test ./internal/provision/ -run '^TestPlan14RootReadCensus$' -count=1`. Expected: PASS.
+- [ ] **Step 6: Commit** (`census: instance-policy-3 sizes, for approval and ruling (#280, #369)`). Rerun Step 3 with the same `NAME` after any later code change. Delete the scratch CLAIMS file.
### Task 14: Copy review
@@ -623,7 +642,7 @@ Collect every user-facing string from Tasks 9 and 10 and run the `copy-review` s
| # | Where | Draft |
|---|---|---|
| 1 | door refusal, `sizeCapacityCheck` | `%s is more than a quarter of the memory on %s (%d MiB). Choose a smaller size.` |
-| 2 | door refusal, missing total | `%s has not published its memory total, so only small and standard can be set. Upgrade the box, then try again.` |
+| 2 | door refusal, missing total | `%s does not publish its memory total, so only small and standard can be set. Upgrade the box, then try again.` (the second sentence fits a platform box on an older release; a fleet box never publishes one, so for it the review should choose the wording) |
| 3 | door refusal, incomplete read | `Could not read every box's memory total, so only small and standard can be set. Try again in a minute.` |
| 4 | `status --layout` heading | `BOX HOST LAYOUT SIZE REASON` (with plan 2: `... SIZE EGRESS REASON`) |
| 5 | CLI after a size change, per box | `%s: %d MiB committed to awake instances, of %d MiB. %d MiB free.` |
@@ -646,3 +665,16 @@ Spec coverage is unchanged (§5.1 table, §5.4 growth, never-shrink and the swap
| 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. |
+
+## Self-review (after spec rev 9 and Astra plan R2)
+
+Spec coverage: §5.5's refusal now covers every box an app can be placed on, not only those with an edges file (Task 9); ruling 9 changes nothing for sizes (under claims a new size waits for the next start, Tasks 5 and 6 as before); ruling 11 and §3a: sizes add no steady-state request (Task 8's counting test; the console reads only on a size change). Names: `sizeBoxes(ctx, acct, recs) ([]sizeBox, bool, error)` and `sizeCapacityCheck(ctx, acct, recs, size)` are used the same way in Task 9's interfaces, rules and tests, and plan 1's door calls `sizeCapacityCheck` where plan 1 says (after size validation, before `checkEgressGrant`); `edgesFilePlacementView` is no longer this plan's return type. Cross-plan: the `SIZE` column before `EGRESS` (Task 10) and the `startGen` line above plan 2's egress call are unchanged; plan 2 no longer has a claim-mode source, so nothing here sits beside one. Placeholders: none. Copy: string 2 reworded as a draft, with a note for the review.
+
+## Review dispositions (Astra plan R2 + spec rev 9)
+
+| Finding or ruling | What changed |
+|---|---|
+| R2-5 (P1) boxes without edges files bypass the missing-total refusal | Task 9: eligible boxes come from the status listing (`status.LoadBoxesComplete`, non-stale) and the account's fleet roster (`ListFleetBoxes`, active), then the published `mem_total_mib` is joined by box name; a box with no file, an unreadable one or no total is unknown and refuses any size above `standard`; an incomplete listing does too. Tests: `TestSizeCapacityRefusesForAnEligibleBoxWithNoEdgesFile` (a box with a status report and no edges file beside a 64 GiB published box), `TestSizeBoxesComeFromTheListingsNotTheEdgesFiles`, `TestSizeCapacityRefusesAboveStandardWhenTheBoxListingIsIncomplete`. |
+| Ruling 9 | No change: sizes under claims already wait for the next start. |
+| Ruling 11, spec §3a | Global Constraints state the budget; Task 8's `TestSizesAddNoBoxStoreRequests` pins that `mem_total_mib` adds no request. |
+| Census (#369) | Task 13 uses the approval flow; the new root units are decision rows written by hand and ruled on #280. |
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 6aab10ff6..7d6864f45 100644
--- a/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md
+++ b/docs/superpowers/plans/2026-10-21-instance-policy-PLAN-BRIEF.md
@@ -2,8 +2,8 @@
For the three plan writers. The spec is
`docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md`
-(rev 8, which folds in what the plan writers found; rev 7 was Astra ACCEPT round 7, approved by Paul 2026-10-21). Its §0 rulings
-and §8 calls are settled; do not reopen them. Its §9 dispositions record
+(rev 9, 2026-10-25: Paul's rulings 9 to 11 and §3a "Request budget"; rev 8 folded in what the plan writers found; rev 7 was Astra ACCEPT round 7, approved by Paul 2026-10-21). Its §0 rulings
+and §8 calls are settled; do not reopen them. Passages marked "[Deferred by ruling 9]" are not in this build. Its §9 dispositions record
why each mechanism is shaped the way it is: read them before simplifying
anything.
@@ -11,9 +11,9 @@ anything.
| # | File | Covers |
|---|---|---|
-| 1 | `2026-10-21-instance-policy-1-foundation.md` | The policy object (spec §3) as a signed record (rev 8: sign, verify, revision, 72 h lifetime, re-sign on the door keys' loop), its validation, the operator door and audit (§6), the CLI (`apps set --egress-allow/--size`, `instances set --size`, `apps show`), the purge sweep and create-time clear (§3), the store door's read grant (§4.3 Transport), and the agent converging the policy into registry columns (pre-claim delivery). |
-| 2 | `2026-10-21-instance-policy-2-egress.md` | The kernel proof first (§4.3), address validation in root (§4.1), root writing and re-asserting `IPAddressAllow=` on every start path and on a 30 s loop (§4.3), the claim-mode store read with signature verification, a per-app revision floor in root's state, a per-app 30 s conditional read and the `app_id` binding (§3, §4.3), the not-wholly-nested rail, `addrs` publication and the grant-time check and report (§4.2), read-back and display (§4.4). |
-| 3 | `2026-10-21-instance-policy-3-sizes.md` | The size table (§5.1), every validator (§5.3), the effective size as the box's tier source (§5.2, §5.3), live growth and the captured tier (§5.4), the cold-start-only memory shrink and swap rule (§5.4), `mem_total_mib` and the capacity check (§5.5), read-back and display (§5.6). |
+| 1 | `2026-10-21-instance-policy-1-foundation.md` | The policy object (spec §3) as a signed record (sign, verify, revision, 72 h lifetime, re-sign on the door keys' loop), its validation, the one-writer lease (ruling 10), the operator door and audit (§6), the CLI (`apps set --egress-allow/--size`, `instances set --size`, `apps show`), the purge sweep and create-time clear (§3), the store door's read grant (§4.3 Transport), the policy key in the change index's app scope (§3a), and the agent converging the policy into registry columns on generation change and the backstop (pre-claim delivery). |
+| 2 | `2026-10-21-instance-policy-2-egress.md` | The kernel proof first (§4.3), address validation in root (§4.1), root writing and re-asserting `IPAddressAllow=` on every start path and on a 30 s loop (§4.3), under claims no allow and `egress: not yet supported under claims` (ruling 9), the not-wholly-nested rail, `addrs` publication and the grant-time check and the gated report (§4.2, §3a), read-back and display (§4.4). |
+| 3 | `2026-10-21-instance-policy-3-sizes.md` | The size table (§5.1), every validator (§5.3), the effective size as the box's tier source (§5.2, §5.3), live growth and the captured tier (§5.4), the cold-start-only memory shrink and swap rule (§5.4), `mem_total_mib` and the capacity check over the listed boxes (§5.5), read-back and display (§5.6). |
Build order: 1, then 2 (Tito is blocked on egress), then 3. Plans 2 and 3
consume plan 1's interfaces below and nothing else from each other, except
@@ -34,8 +34,12 @@ Plan 1 produces these; plans 2 and 3 use them by these exact names.
- `func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Policy, error)`: signature, shape, unexpired; errors wrap `ErrUnverifiable` or `ErrExpired`.
- `func VerifyRevision(pub ed25519.PublicKey, wire []byte) (uint64, error)`: the revision of a validly signed record, expired or not.
- `func VerifyKey(pub ed25519.PublicKey, wire []byte, now time.Time, sqid, app string) (Policy, error)`: `Verify` plus "the record names this account and app".
- - `func Get(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string, now time.Time) (*Policy, string, error)`: the verified policy (`VerifyKey`). Nil policy and `os.ErrNotExist` when absent; nil policy, the ETag and `ErrUnverifiable` / `ErrExpired` when the object does not verify.
- - `func PutIfMatch(ctx context.Context, s release.Store, priv ed25519.PrivateKey, p Policy, etag string) error`: signs `p` and writes it at `Key(p.Account, p.App)`. The console's door signs every write at a revision one above the current, and re-signs every live policy on the door keys' 12-hour loop.
+ - `func Get(ctx context.Context, s release.Store, pub ed25519.PublicKey, sqid, app string, now time.Time) (*Policy, string, error)`: the verified policy (`VerifyKey`). Nil policy and `os.ErrNotExist` when absent; nil policy, the ETag and `ErrUnverifiable` / `ErrExpired` when the object does not verify. The agent is its only box-side caller; root calls nothing in `apppolicy` in this build (ruling 9).
+ - `func ListETags(ctx context.Context, s release.Store) (map[string]string, error)`: one listing of `Prefix`, `"<sqid>/<app>"` to ETag; `errors.ErrUnsupported` from a store that is not a `release.ETagLister`.
+ - `func PutIfMatch(ctx context.Context, s release.Store, priv ed25519.PrivateKey, p Policy, etag string) error`: signs `p` and writes it at `Key(p.Account, p.App)`. The console's door signs every write at a revision one above the current, and re-signs every live policy on the door keys' 12-hour loop, both only while it holds the writer lease.
+ - `AppID` is stamped by the console under claims and used only by the console's door (a predecessor's policy starts empty); no box reads it in this build.
+- **Change index** (plan 1, spec §3a): `release.ChangeScope("control/app-policy/<sqid>/<app>.json")` is `"apps/<sqid>/<app>", true`, pinned in `TestChangeScope`. `provision.PolicyCache` (`NewPolicyCache(watch *release.ChangeWatch)`, set as `ReconcileConfig.Policies`) is the agent's only policy reader: a read on that app's generation move, a failed read's retry, or the backstop (one `ListETags`, GET only changed ETags). No other plan reads the policy from the store.
+- **Writer lease** (plan 1, ruling 10): `internal/lease` (the platform's one election, as the housekeeper uses it) at `control/app-policy-lease/lock.json` in the deployment bucket, TTL `lease.LeaseTTL` (15 min), renewed every TTL/3, released on SIGTERM; acquired lazily on the first signing. `console.PolicyLeaser`, `Config.PolicyLease`, `(*Server).policyWriter(ctx) (policyHolding, *doorRefusal)`, `policyHeld() bool`, `ReleasePolicyWriter(ctx)`. A second console refuses with 503 naming the lock's owner and expiry; after the holder's lease lapses it takes it and builds on the bucket's verified record. `CARLOS_DEPLOYMENT_DIR` deployments are sole (no lease). `(*Store).AppPolicyRowsContext(ctx) ([]AppPolicyRow, error)`: the console's recorded policies; plan 2's report reads live allows from it.
- `func Delete(ctx context.Context, s release.Store, sqid, app string) error`
- `func (p Policy) Validate() error`: shape only (version, size names known to `cgroup.SizeByName`, at most 8 allows, each parsing under `egressaddr.Parse`).
- **Package `internal/egressaddr`** (new, plan 1 creates it because the door validates; plan 2 extends nothing in it, only calls it):
@@ -50,11 +54,12 @@ 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.
- - `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.
+ - `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. Under claims root reads neither egress column (ruling 9).
- `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.
+- **Egress under claims** (plan 2, ruling 9): `instancectl.EgressSource` is `func(ctx, host string) (EgressPolicy, error)`; `EgressPolicy{Allow []string; Closed string; Detail string}`, `Detail` carried into `EgressReport.Detail` (Astra plan R2-4). With claims on, root's source answers every host `Closed: "under_claims"`, `Detail: "not yet supported under claims"` (shown as `egress: not yet supported under claims`): every assert's desired list is empty, a held allow is reset, an empty slice is never written.
- **Sizes under claims:** no live change; a new size takes effect at the host's next start (spec §5.4 rev 8). No plan implements a below-tier CLI warning (spec §5.2 rev 8 drops it).
Any name a plan needs that is not here is that plan's to choose and pin in
@@ -78,10 +83,33 @@ its own Interfaces blocks.
- **Cross-compile:** CI builds darwin and windows. Linux-only syscalls in
`internal/host` or `internal/instancectl` need `//go:build linux` and a
`!linux` stub.
-- **Root code** (`internal/instancectl`, root's paths in `internal/host`):
- the plan ends with a census overlay task (see existing
- `docs/superpowers/notes/census-proposal-*.md` and the `#280` thread); the
- overlay loads after `plan14-root-reads-ci-speed-activator`.
+- **Root code** (`internal/instancectl`, root's paths in `internal/host`,
+ and packages in root's build graph): the plan ends with a census task in
+ the #369 approval flow (AGENTS.md "The census: an approval, not a
+ decision"): `make census-card`, a CLAIMS file, `make census-propose
+ NAME=<branch> CLAIMS=<file>`, the card shown to Paul for approval; rows
+ of a decision class (`reads`, `new`, `removed`, `other`) are written by
+ hand and ruled on #280. The tool appends the overlay to
+ `testdata/plan14-root-reads-chain.tsv`, so it loads after
+ `ci-speed-activator`, `securelisten-337` and every earlier overlay (plan
+ 1's, then 2's, then 3's).
+- **Request budget** (spec §3a, ruling 11): no reader polls the policy on
+ its own timer. Steady state with nothing written, added per box per day:
+ at most 48 LISTs and 0 GETs; per console per day: 2 GETs per live policy
+ (the re-sign) plus 48 LISTs (plan 2's report). Root reads no store for
+ egress; sizes add no store request. Each plan has a counting-store test
+ that pins its part and fails on any increase. The writer lease's renewals
+ (288 PUTs a day on the one writer console) go through `internal/lease`,
+ outside the counted store, and are stated in plan 1 Task 8.
+- **One policy writer per bucket** (ruling 10): nothing signs a policy
+ without the writer lease; a second console refuses and names the holder.
+- **Claims** (ruling 9): no plan builds root's claim-mode egress source; with
+ claims on root writes no allow and says so. Sizes under claims are
+ unchanged: a new size waits for the next start.
+- **Console invariant** (main 521c6ee): a new door or worker fixture in
+ `TestClaimConsoleMutationInvariant` that uses `t.Setenv` or swaps
+ process-wide state is listed in `claimSerialDoors` with its reason and
+ calls `claimMustRunAlone(t, ...)` before the swap.
- **Production-wired witnesses:** claim and root behaviour is tested
through the real control service, units and wiring, not fixtures or
sleeps.
diff --git a/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md b/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md
index 1ac945fe8..072805643 100644
--- a/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md
+++ b/docs/superpowers/specs/2026-10-21-instance-sizes-and-egress-design.md
@@ -1,7 +1,7 @@
# Instance sizes and egress allows
Amadan #328 (an isolated instance reaching a private service its operator
-runs) and #329 (operator-set CPU and memory per instance). Revision 9,
+runs) and #329 (operator-set CPU and memory per instance). Revision 9 (with its plan-writer cost notes),
2026-10-25. Revision 9 adds Paul's rulings 9 to 11 (claim-mode
egress deferred, one policy writer per bucket, a request budget, §3a).
Revision 8 folds in what the three plan writers found
@@ -239,12 +239,24 @@ nothing changed**.
policy once per 12 hours, as door keys do.
- **Sizes** add no store reads: `mem_total_mib` rides the edges file the
box already writes, on change only.
-- **Budget, stated and tested:** with no policy changes, the added
+- **Budget, stated and tested:** with no operator changes, the added
steady-state cost per box per day is at most 48 LISTs (the backstop's
- one per 30 minutes) and zero GETs; per console per day, 2 GETs per live
- policy (re-sign) plus 48 LISTs. Tests count store calls through a
+ one per 30 minutes) plus, for each live policy the box hosts, the two
+ re-reads its 12-hourly re-sign triggers (the re-sign bumps the app's
+ scope so boxes pick up the new expiry: 2 GETs and the app's ordinary
+ generation re-read, twice a day). Per console per day: 2 GETs per live
+ policy (re-sign) plus 48 LISTs, and, on the one console holding the
+ writer lease (ruling 10, `internal/lease`, the housekeeper's lock), one
+ conditional PUT per 5-minute renewal: 288 PUTs a day, about $0.04 a
+ month, independent of apps and boxes. Tests count store calls through a
counting store wrapper on the agent's reconcile and the console's loops
- and fail on any increase over that.
+ and fail on any increase over that. Nothing here grows per pass.
+- **Stated limits:** a disarmed box (#336) sees a policy change only at
+ the backstop, up to about 33 minutes; the flagship role's `DeleteObject`
+ Deny stops a rolled console releasing the lease, so its successor waits
+ up to 15 minutes before it can write a policy; bucketless fleet boxes
+ publish no memory total, so a fleet account's apps can be sized
+ `standard` or below until they do.
## 4. Egress allow (#328)