carlos / platform Public

plan 1: Astra plan review R4 (create under the writer holding, pending re-sign rows, witness TTLs)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 424aa4ff926f467ee900ef5f23644c231225e98b parent 9061955
2 files changed, +82 −26
  • docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md +70 −26
  • docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R4.md +12 −0

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.016s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (2 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (153 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	7.392s
ok  	github.com/carlosframework/platform/cmd/moveprobe	13.907s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.829s
ok  	github.com/carlosframework/platform/internal/activator	61.188s
ok  	github.com/carlosframework/platform/internal/agentsock	0.043s
ok  	github.com/carlosframework/platform/internal/alerting	0.006s
ok  	github.com/carlosframework/platform/internal/analytics	0.099s
ok  	github.com/carlosframework/platform/internal/appconfig	6.541s
ok  	github.com/carlosframework/platform/internal/apperr	0.205s
ok  	github.com/carlosframework/platform/internal/applog	0.612s
ok  	github.com/carlosframework/platform/internal/assetcache	0.323s
ok  	github.com/carlosframework/platform/internal/awsinv	0.006s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.008s
ok  	github.com/carlosframework/platform/internal/boxid	1.264s
?   	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.056s
ok  	github.com/carlosframework/platform/internal/caps	0.013s
ok  	github.com/carlosframework/platform/internal/certwarm	0.004s
ok  	github.com/carlosframework/platform/internal/cgroup	1.147s
ok  	github.com/carlosframework/platform/internal/changelog	0.820s
ok  	github.com/carlosframework/platform/internal/claimactivation	2.609s
ok  	github.com/carlosframework/platform/internal/clamd	0.078s
?   	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	8.492s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.035s
ok  	github.com/carlosframework/platform/internal/dbquery	0.022s
ok  	github.com/carlosframework/platform/internal/dbreads	2.398s
?   	github.com/carlosframework/platform/internal/dbreads/dbreadstest	[no test files]
ok  	github.com/carlosframework/platform/internal/dbverify	0.079s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.006s
ok  	github.com/carlosframework/platform/internal/docsite	0.020s
ok  	github.com/carlosframework/platform/internal/domains	5.146s
ok  	github.com/carlosframework/platform/internal/doorkey	0.009s
ok  	github.com/carlosframework/platform/internal/economics	0.006s
ok  	github.com/carlosframework/platform/internal/edge	50.190s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.016s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.360s
ok  	github.com/carlosframework/platform/internal/gate	2.499s
ok  	github.com/carlosframework/platform/internal/genselect	0.075s
ok  	github.com/carlosframework/platform/internal/geo	0.039s
ok  	github.com/carlosframework/platform/internal/geoip	0.057s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.774s
ok  	github.com/carlosframework/platform/internal/host	148.700s
ok  	github.com/carlosframework/platform/internal/hostactors	10.171s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.009s
ok  	github.com/carlosframework/platform/internal/hostbox	6.243s
ok  	github.com/carlosframework/platform/internal/hostclaim	2.420s
ok  	github.com/carlosframework/platform/internal/hostenv	0.015s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.008s
ok  	github.com/carlosframework/platform/internal/hostexec	11.054s
ok  	github.com/carlosframework/platform/internal/hostfence	0.096s
ok  	github.com/carlosframework/platform/internal/hostfiles	13.863s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.039s
ok  	github.com/carlosframework/platform/internal/hostguard	1.213s
ok  	github.com/carlosframework/platform/internal/hostops	5.296s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.006s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.008s
ok  	github.com/carlosframework/platform/internal/hostrelocate	2.114s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.673s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.011s
ok  	github.com/carlosframework/platform/internal/instancectl	5.348s
ok  	github.com/carlosframework/platform/internal/instanceexec	3.364s
ok  	github.com/carlosframework/platform/internal/instancefs	0.016s
ok  	github.com/carlosframework/platform/internal/instancekey	0.003s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.016s
ok  	github.com/carlosframework/platform/internal/layout	4.037s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.005s
ok  	github.com/carlosframework/platform/internal/lease	10.355s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	1.227s
ok  	github.com/carlosframework/platform/internal/ledger	0.019s
ok  	github.com/carlosframework/platform/internal/mail	0.811s
ok  	github.com/carlosframework/platform/internal/maintenance	0.098s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	6.169s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.340s
ok  	github.com/carlosframework/platform/internal/metrics	0.028s
ok  	github.com/carlosframework/platform/internal/moves	27.755s
ok  	github.com/carlosframework/platform/internal/moves/archive	2.426s
ok  	github.com/carlosframework/platform/internal/objstore	0.766s
ok  	github.com/carlosframework/platform/internal/ops	0.215s
ok  	github.com/carlosframework/platform/internal/park	0.811s
ok  	github.com/carlosframework/platform/internal/plans	0.011s
ok  	github.com/carlosframework/platform/internal/privatefile	0.016s
ok  	github.com/carlosframework/platform/internal/provision	24.903s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.057s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	8.114s
ok  	github.com/carlosframework/platform/internal/relay	7.297s
ok  	github.com/carlosframework/platform/internal/release	34.539s
ok  	github.com/carlosframework/platform/internal/rollout	5.879s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.013s
ok  	github.com/carlosframework/platform/internal/rootaws	0.012s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.062s
ok  	github.com/carlosframework/platform/internal/rootstate	0.038s
ok  	github.com/carlosframework/platform/internal/runs	0.805s
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	4.526s
ok  	github.com/carlosframework/platform/internal/seeds	0.039s
ok  	github.com/carlosframework/platform/internal/sim/engines	7.143s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	26.533s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.152s
ok  	github.com/carlosframework/platform/internal/status	0.169s
ok  	github.com/carlosframework/platform/internal/steering	0.007s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.362s
ok  	github.com/carlosframework/platform/internal/svcrole	0.003s
ok  	github.com/carlosframework/platform/internal/telemetry	0.006s
ok  	github.com/carlosframework/platform/internal/termfence	0.012s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.008s
ok  	github.com/carlosframework/platform/internal/unixdial	0.005s
ok  	github.com/carlosframework/platform/internal/updatewords	0.005s
ok  	github.com/carlosframework/platform/internal/usage	0.009s
ok  	github.com/carlosframework/platform/internal/version	0.033s
ok  	github.com/carlosframework/platform/internal/vet	1.965s
=== internal_console@0 (88 s)
ok  	github.com/carlosframework/platform/internal/console	84.087s
=== internal_console@1 (48 s)
ok  	github.com/carlosframework/platform/internal/console	44.302s
=== internal_console@2 (52 s)
ok  	github.com/carlosframework/platform/internal/console	46.831s
=== internal_console@3 (54 s)
ok  	github.com/carlosframework/platform/internal/console	48.957s
=== internal_console@4 (51 s)
ok  	github.com/carlosframework/platform/internal/console	45.921s
=== internal_console@5 (46 s)
ok  	github.com/carlosframework/platform/internal/console	41.839s
=== internal_console@6 (51 s)
ok  	github.com/carlosframework/platform/internal/console	46.781s
=== internal_console@7 (55 s)
ok  	github.com/carlosframework/platform/internal/console	50.176s
=== root@0 (117 s)
ok  	github.com/carlosframework/platform	114.188s
=== root@1 (107 s)
ok  	github.com/carlosframework/platform	104.096s
=== root@2 (83 s)
ok  	github.com/carlosframework/platform	80.867s
=== 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.03s)
    --- 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.02s)
        --- 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.033s
=== RUN   TestT4StorageProbeMinio
2026/10/25 00:32:16 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E19E7178112FAD, 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.029s
=== RUN   TestS3StoreMinioContract
=== RUN   TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN   TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.03s)
    --- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
    --- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.02s)
PASS
ok  	github.com/carlosframework/platform/internal/release	0.042s
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:32:19 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/25 00:32:19 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/25 00:32:19 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/25 00:32:19 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/25 00:32:19 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/25 00:32:19 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/25 00:32:19 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/25 00:32:19 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/25 00:32:19 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/25 00:32:19 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/25 00:32:19 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/25 00:32:19 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/25 00:32:19 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/25 00:32:19 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%3A38145: could not determine target mailbox
    agent.go:135: dbreads: dbr_2ixc3ggxc7tkjzfqpzxt3yqlye: planned 3 files (1544 bytes) and restored to 3 in 14ms
    agent.go:135: dbreads: dbr_2ixc3ggxc7tkjzfqpzxt3yqlye: output 5 bytes in 1 confirmed chunks in 2ms
2026/10/25 00:32:20 console: db read dbr_2ixc3ggxc7tkjzfqpzxt3yqlye: relayed 1 chunks (102 bytes) in 1.006s, ended done
    agent.go:135: dbreads: dbr_gs3jife3fzcx7ni2qdtschrk7i: planned 1 files (720 bytes) and restored to 1 in 12ms
    agent.go:135: dbreads: dbr_gs3jife3fzcx7ni2qdtschrk7i: output 5 bytes in 1 confirmed chunks in 2ms
2026/10/25 00:32:21 console: db read dbr_gs3jife3fzcx7ni2qdtschrk7i: relayed 1 chunks (102 bytes) in 1.006s, ended done
    agent.go:135: dbreads: dbr_cjmo6pnc6o4qwfsws4kzgc27ki: planned 3 files (1544 bytes) and held through 3 in 13ms
    agent.go:135: dbreads: dbr_cjmo6pnc6o4qwfsws4kzgc27ki: output 1544 bytes in 1 confirmed chunks in 2ms
2026/10/25 00:32:22 console: db read dbr_cjmo6pnc6o4qwfsws4kzgc27ki: relayed 1 chunks (1641 bytes) in 1.006s, ended done
=== RUN   TestDBForwardMinio/by_disconnect
=== NAME  TestDBForwardMinio
    agent.go:135: dbreads: dbr_n23neqd4hg3qaya744x7e54hfq: planned 3 files (1544 bytes) and restored to 3 in 14ms
2026/10/25 00:32:22 console: db read dbr_n23neqd4hg3qaya744x7e54hfq: relayed 0 chunks (0 bytes) in 33ms, ended client gone
    agent.go:135: dbreads: dbr_n23neqd4hg3qaya744x7e54hfq: output 5 bytes in 1 confirmed chunks in 46ms
=== RUN   TestDBForwardMinio/by_timeout
=== NAME  TestDBForwardMinio
    agent.go:135: dbreads: dbr_gi6jxg3g7bd57v74e5667rtvpq: planned 3 files (1544 bytes) and restored to 3 in 19ms
2026/10/25 00:32:42 console: db read dbr_gi6jxg3g7bd57v74e5667rtvpq: relayed 0 chunks (0 bytes) in 19.105s, ended no-result
    agent.go:135: dbreads: dbr_gi6jxg3g7bd57v74e5667rtvpq: output 5 bytes in 1 confirmed chunks in 19.076s
--- PASS: TestDBForwardMinio (22.53s)
    --- PASS: TestDBForwardMinio/by_disconnect (0.19s)
    --- PASS: TestDBForwardMinio/by_timeout (19.22s)
PASS
ok  	github.com/carlosframework/platform/internal/console	22.552s
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 661b8a833..8d6a3850f 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
@@ -55,7 +55,7 @@ Everything in the brief's "Cross-plan interfaces", plus:
- `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) policyAttempt(ctx context.Context, h policyHolding) (context.Context, context.CancelFunc, *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.
+- `func (s *Store) AppPolicyRowsContext(ctx context.Context) ([]AppPolicyRow, error)` (Task 9): every recorded policy that is synced or pending, with its `synced_etag` and `Pending` (Astra plan R4-2); 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`. `GET /store/etags?prefix=` (Task 5) answers the box's own `control/app-policy/<sqid>/` keys with their ETags, and `fleetapi`'s store is a `release.ETagLister` over it, so a fleet box's backstop is one LIST and GETs only changed policies.
- 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.
@@ -722,11 +722,11 @@ Why this mechanism: it is the platform's one election, `internal/lease` (litestr
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.
+- **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; an app create, whose clear runs inside the writer's exclusion, Task 11) 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.
- **Every write is bound to its holding** (Astra plan R3-1). A caller keeps the `policyHolding` its `policyWriter` call returned and never asks for a new one mid-write. `policyAttempt(ctx, h)` is the check before each signing and each publication attempt: sole → `ctx` unchanged, a no-op cancel, no refusal. Otherwise it refuses with 503 `policyLeaseLostMsg` unless `h` is still the server's current holding (`h.n` equals it: a later holding does not revive an earlier one), `h.lease.Err()` is nil, `h.lease.SafeUntil()` succeeds and `safe` is at least `policyAttemptWindow` away. On success it returns a context that ends at the earlier of `now + policyAttemptWindow` and `safe`, and as soon as `h.lease.Lost()` closes. A refusal for a lost lease also ends the holding as `policyWriter` does. An attempt cut off by that context counts as the same refusal, never as a store failure. Bound, stated: `safe` is 5 s before the lease's expiry and no other console can acquire until that expiry, so an attempt either reaches the store before another console can hold the lease or is cancelled; a request the network holds back past its own cancellation is the residual, and a later holder's CAS write then sees a changed ETag (Task 9 step 11).
- **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.
+- **Cost** (outside `release.Store`; stated, not counted by the §3a tests): nothing on a console that never signs a policy or creates an app. 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, an app create 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`; `policyLeaseLostMsg` = `this console stopped being the policy writer during the change, so it may not have been published. Read the policy again before you retry`.
@@ -796,14 +796,14 @@ func TestPolicyLeaseForFollowsTheDeploymentBucket(t *testing.T) {
**Interfaces:**
- Consumes: Tasks 1-3, 6, 8 (`policyWriter`, `policyHolding`, `policyAttempt`, `policyLeaseLostMsg`); `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, 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 }`
+ - `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 '', pending INTEGER NOT NULL DEFAULT 0, 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; `''` = no publication of this row confirmed. `pending` (Astra plan R4-2) tells the two kinds of `''` apart: `1` = signed bytes recorded whose publication is not yet confirmed (set by every record and re-sign, cleared only by a confirmed publication, an adoption, or a read that found the object absent), so a first publication whose bucket write landed and whose sync-marker write failed stays visible; `0` with `''` = confirmed absent (never published, or found absent by Task 10).
+ - `type AppPolicyRow struct { AccountID int64; Sqid, App string; Revision uint64; Wire []byte; SyncedETag string; Pending bool }`
- `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`.
+ - `func (s *Store) AppPolicyRowsContext(ctx context.Context) ([]AppPolicyRow, error)`: every row whose `synced_etag` is not `''` **or** whose `pending` is `1`, joined to its account's sqid (Task 10's re-sign and plan 2's report read this, so neither lists the bucket; a pending row is a live policy as far as either knows, until a read finds its object absent).
+ - `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 and `pending` is set to `1`. 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`, `pending = 0`, 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 = etag, pending = 0` where `revision = rev` (after a publication of that revision landed; `etag ""` is Task 10's "found absent"), else `errAppPolicyRevisionMoved`. It is the only write that clears `pending` for a recorded revision.
+ - `func (s *Store) ResignAppPolicyContext(ctx context.Context, accountID int64, app string, rev uint64, wire []byte) error`: replaces `wire` and sets `pending = 1` only where `revision = rev` (the re-sign: same revision, same content, a later `signed_at`), else `errAppPolicyRevisionMoved`.
- 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.
@@ -821,7 +821,7 @@ PUT order (each refusal writes nothing):
- `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 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, `s.policyAttempt(ctx, hold)` first, the holding step 7 took (a refusal → 503 `policyLeaseLostMsg`: nothing audited, recorded or written); then `auditRequired` (failure → 503, no write); then set `p.Account, p.App, p.Revision, p.SignedAt = acct.Sqid, app, next, time.Now().UTC()`, `policyAttempt(ctx, hold)` again (the check before signing; a refusal → 503 `policyLeaseLostMsg`, nothing recorded), `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 publish, **each attempt bound to the holding** (Astra plan R3-1): every attempt, the first and each retry, begins `actx, cancel, ref := s.policyAttempt(ctx, hold)`; a refusal, or `actx` ending during the attempt, → 503 `policyLeaseLostMsg` and no further attempt. The attempt is `apppolicy.PutWire(actx, 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 a live holding 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 with `GetForUpdate(actx, ...)` under that attempt's check. A record that verifies for this key at a revision of `next` or above with bytes other than `wire` is another writer's (only the release key signs one) → 503 `policyLeaseLostMsg`, never overwritten. Otherwise take its ETag and start the next attempt with the same bytes, 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. After a lost holding the recorded revision is published only if this console's next holding finds the bucket still holding its last publication (Task 9 step 7's new-holding rule); otherwise the next holding builds on the other writer's record. 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, `s.policyAttempt(ctx, hold)` first, the holding step 7 took (a refusal → 503 `policyLeaseLostMsg`: nothing audited, recorded or written); then `auditRequired` (failure → 503, no write); then set `p.Account, p.App, p.Revision, p.SignedAt = acct.Sqid, app, next, time.Now().UTC()`, `policyAttempt(ctx, hold)` again (the check before signing; a refusal → 503 `policyLeaseLostMsg`, nothing recorded), `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 publish, **each attempt bound to the holding** (Astra plan R3-1): every attempt, the first and each retry, begins `actx, cancel, ref := s.policyAttempt(ctx, hold)`; a refusal, or `actx` ending during the attempt, → 503 `policyLeaseLostMsg` and no further attempt. The attempt is `apppolicy.PutWire(actx, s.cfg.Releases, acct.Sqid, app, wire, etag)`; on success `SyncedAppPolicyContext(ctx, acct.ID, app, next, newETag)` (a failure is logged only: the row stays `pending`, so it stays among Task 10's candidates on every pass, never excluded, and the next touch finds the bytes equal to `rw` and treats them as this console's; Astra plan R4-2). `ErrCASConflict` (the object changed after step 7, which under the lock and a live holding 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 with `GetForUpdate(actx, ...)` under that attempt's check. A record that verifies for this key at a revision of `next` or above with bytes other than `wire` is another writer's (only the release key signs one) → 503 `policyLeaseLostMsg`, never overwritten. Otherwise take its ETag and start the next attempt with the same bytes, 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. After a lost holding the recorded revision is published only if this console's next holding finds the bucket still holding its last publication (Task 9 step 7's new-holding rule); otherwise the next holding builds on the other writer's record. 12. `s.bumpSerial(ctx)`. 13. 200 with the reply.
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)`.
@@ -881,7 +881,10 @@ func TestAppPolicyRevisionsAreNeverReused(t *testing.T) {
// 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
+ // AppPolicyRowsContext lists rows with a synced etag or Pending, each with its account's sqid
+ // Astra plan R4-2: RecordAppPolicyContext on a new row -> Pending true, SyncedETag "", listed;
+ // SyncedAppPolicyContext(rev, "e1") -> Pending false; ResignAppPolicyContext -> Pending true;
+ // SyncedAppPolicyContext(rev, "") (found absent) -> Pending false, SyncedETag "", not listed
}
func TestPolicyBaseRebasesOnANewHolding(t *testing.T) {
// Ruling 10's takeover, with Task 8's fake leaser (cfg.Bucket "deploy").
@@ -978,10 +981,10 @@ func TestPolicyPutStampsTheAppIDUnderClaims(t *testing.T) {
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.
-- **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.
+- **Which apps, with no listing in steady state** (spec §3a). The candidates are `AppPolicyRowsContext`'s rows, read on **every** pass (every policy this console published or adopted, and every pending one: recorded, its publication not yet confirmed, Task 9; Astra plan R4-2). So a first publication whose sync-marker write failed after the holding's listing is still re-signed; the listing is never its only way back. 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. The pass keeps that one holding, and every app's signing and publication go through `policyAttempt(ctx, hold)` (below), so a holding lost part way through the pass stops it (Astra plan R3-1): log `console: app policies: stopped re-signing at %s/%s; the writer lease was lost`, and no later candidate is read or written in that pass.
- 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 object is absent → `SyncedAppPolicyContext(row.Revision, "")` (confirmed absence: `pending` cleared, so 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: `policyAttempt(ctx, hold)` (the check before signing), then that content at the row's **recorded revision** with `SignedAt = now`; `ResignAppPolicyContext(rev, wire')` before the write (the record never lags the bucket); then `actx, cancel, ref := s.policyAttempt(ctx, hold)` and `PutWire(actx, ..., wire', etag)` (one attempt, replace-only on the ETag just read, so a purge that deleted the key between the read and the write is never undone, and a writer that took over meanwhile is never overwritten; `ErrCASConflict` and a not-found are both logged and skipped, never retried); a refusal from either check, or `actx` ending, stops the pass as above; 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.
@@ -1023,6 +1026,18 @@ func TestResignStopsWhenTheHoldingIsLost(t *testing.T) {
// a Releases wrapper sets the fake lease's Err once the first policy Put returns
// -> exactly 1 policy re-signed; the other two objects byte-identical; no further Get or Put of the prefix
}
+// Astra plan R4-2. Fails if pending rows are left out of the candidates.
+func TestResignPicksUpAFirstPublicationWhoseSyncMarkFailed(t *testing.T) {
+ // sole fixture: PUT a policy for shop (synced); resignAppPolicies once (this holding's listing has run)
+ // a test trigger on fx.st's database aborts UPDATEs of app_policy_revisions.synced_etag to a non-empty value
+ // PUT If-None-Match:* {"egress_allow":["10.0.5.20"]} for shop-b -> 200; the object verifies at Revision 1;
+ // the row: SyncedETag "", Pending true; AppPolicyRowsContext lists it
+ // resignAppPolicies (trigger still on): shop-b's SignedAt moved, the row still Pending (its marker failed again)
+ // drop the trigger; resignAppPolicies: SignedAt moved again, Revision 1; the row Pending false,
+ // SyncedETag the stored etag; no List of the prefix in either pass
+ // delete shop-b's object directly; resignAppPolicies -> nothing written, the row Pending false and SyncedETag "";
+ // the next pass makes no Get of shop-b's key
+}
func TestResignWithNoPoliciesTakesNoLease(t *testing.T) {
// cfg.Bucket "deploy", the fake leaser: an empty bucket and table -> one Present listing, 0 leaser calls
}
@@ -1042,26 +1057,28 @@ 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).
+ // PolicyLeaseTTL policyWitnessTTL (30 s, renewed every 10 s by internal/lease; Astra plan R4-3, below).
+ // First line: policyWitnessTTL >= 3*lease.RenewMargin (so the margin is RenewMargin, not TTL/10) and
+ // policyWitnessTTL - policyWitnessTTL/3 - lease.RenewMargin >= policyAttemptWindow, else t.Fatal.
// 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
+ // poll (policyLapseDeadline) 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
}
// Astra plan R3-1: a writer paused inside a write must not overwrite the next writer's revoke.
-// Same world as above (one leasetest bucket, two console databases, TTL 3s, not parallel).
+// Same world as above (one leasetest bucket, two console databases, policyWitnessTTL, not parallel).
func TestALapsedWriterCannotOverwriteTheNextWritersRevoke(t *testing.T) {
// 1. A: PUT egress ["10.0.5.20"] -> 200, rev 1 (A holds)
// 2. A.policyAfterBase blocks on a channel; A: PUT {"default_size":"small"} If-Match <rev 1's etag> in a
// goroutine; wait until the seam is entered (A has read the allow as its base)
- // 3. FailOn refuses puts to the lock key; poll (5 s deadline) until A.policyHeld() is false and the
+ // 3. FailOn refuses puts to the lock key; poll (policyLapseDeadline) until A.policyHeld() is false and the
// lock's ExpiresAt has passed; clear FailOn
// 4. B: GET -> egress 10.0.5.20; B: PUT {"egress_allow":[]} If-Match <that etag> -> 200, rev 2; keep the bytes wB
// 5. release A's seam: A's PUT -> 503 policyLeaseLostMsg; the object is wB byte for byte (no egress_allow)
@@ -1072,9 +1089,11 @@ func TestALapsedWriterCannotOverwriteTheNextWritersRevoke(t *testing.T) {
}
```
+**The witnesses' TTL (Astra plan R4-3).** Both two-console tests use the production leaser and the production `policyAttempt` checks; only the TTL is the test's. `policylease_twoconsoles_test.go` pins `const policyWitnessTTL = 30 * time.Second` and `const policyLapseDeadline = 45 * time.Second`. Why 30 s: `internal/lease` subtracts `lease.RenewMargin` (5 s) from the expiry for a TTL of 15 s or more and a tenth of the TTL below that (`marginForTTL`, `health.go:28-33`; `until = started + TTL - margin`, `health.go:53`), and renews every TTL/3. So at 30 s `SafeUntil` is 25 s ahead after a renewal and never less than 15 s ahead before the next one: always above `policyAttemptWindow` (10 s), so a live holding's attempt is admitted at any moment. At the old 3 s it was 2.7 s, below the window, so every first write was refused. Why 45 s: after `FailOn` the next renewal fails within 10 s (`policyHeld` false) and the lock's `ExpiresAt` passes at most 30 s after the last renewal that landed; 45 s is that plus `RenewMargin` and 10 s of slack. Each lapse now costs up to about 35 s, so the first test runs about 40 s and the second about 80 s.
+
- [ ] **Step 2: Run, fail:** `go test ./internal/console -run 'TestResign|TestStartDoorKeyResignAlsoResignsPolicies|TestPolicyResignKeepsTheRequestBudget|TestPolicyWriterLeaseIsOneConsolePerBucket|TestALapsedWriterCannotOverwriteTheNextWritersRevoke|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), that nothing is signed without the writer lease (ruling 10), and that the whole pass is bound to one holding through `policyAttempt` (Astra plan R3-1).
-- [ ] **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 4: Run, pass.** Same command, plus `go test ./internal/console -run 'TestDoorKey'` (the door keys' own pass is unchanged). The two-console tests run for tens of seconds (above), so 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)`.
---
@@ -1084,12 +1103,13 @@ func TestALapsedWriterCannotOverwriteTheNextWritersRevoke(t *testing.T) {
**Files:**
- Modify: `internal/release/trash.go:276-295` (append `AppPolicyKey(acct, app)` to `keys` before the delete loop, with a comment in the ledger paragraph's style)
- Modify: `internal/console/server.go`: `claimOutcome` (4103: `claimPolicyClearFailed`), `claimAppWith` (its `createClaimAppRow` call, 4341-4349), new `clearAppPolicy`, the browser door's outcome switch (4458); `internal/console/claim_app_enroll.go` `createClaimAppRow` (16-44); `internal/console/apicli.go` (the create door's outcome switch, 465-483); `internal/console/apitemporary.go` (its outcome switch, 205-219)
-- Test: `internal/release/trash_test.go`; `internal/console/apipolicy_test.go`
+- Modify (tests): every console fixture that sets `cfg.Bucket` and creates an app through a door sets `cfg.PolicyLease` to Task 8's fake leaser, since create now takes the writer holding: the claim world's config (`claim_invariant_world_test.go:516`, already done for `newClaimCase` in Task 9) and each `fx.srv.cfg.Bucket = ...` site (`grep -n 'cfg.Bucket = ' internal/console/*_test.go`) whose test creates an app
+- Test: `internal/release/trash_test.go`; `internal/console/apipolicy_test.go`; `internal/console/regionclaim_test.go` (`TestClaimLostAfterResolutionWritesNothing`, ~187)
**Interfaces:**
-- Consumes: `release.AppPolicyKey`, `apppolicy.Delete`, `s.appMutation`, `claimEnrollmentContext` (`claim_app_admission.go:189`) and `mutationAppID`'s enrollment branch (212-220).
-- Produces: `claimPolicyClearFailed` (a `claimOutcome`); `func (s *Server) clearAppPolicy(ctx context.Context, acct *Account, app string) error` = `s.appMutation(ctx, acct, app, "app-policy-clear", func(ctx) error { return apppolicy.Delete(ctx, s.cfg.Releases, acct.Sqid, app) })`, wrapped in `errAppPolicyClear` on failure; `createClaimAppRow` keeps its signature.
-- 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 never succeeds while a policy object at the key cannot be deleted, in every mode** (spec §3: "before the name is usable"; Astra plan R3-3). `createClaimAppRow` calls `clearAppPolicy` **before the row is inserted**: with `HostClaim` off, first thing; with `HostClaim` on, after `EnrollNewApp` and `claimEnrollmentContext`, with the enrollment context, whose identity `mutationAppID` already answers before the row exists, so the boundary admits the app-scoped delete (Task 6), and before `withAutoAppSQL("sql.CreateApp")`. A failure (a store error, or the boundary refusing) inserts no row and returns `errAppPolicyClear`; `claimAppWith` maps it to `claimPolicyClearFailed`. An enrollment left with no row is the state an insert failure after `EnrollNewApp` leaves today, so a retry takes that existing path. The doors answer `claimPolicyClearFailed` as retryable: the CLI create door and `/new-temporary` 503 `appCreatePolicyClearMsg`, the browser door the flash `appCreatePolicyClearFlash`. A retry of the create re-runs the clear. Applies to the owner's adopt-existing case too. Task 9 step 7's `AppID` rule stays, for an object an older console's create left (spec §3 "Older consoles"); nothing here reads `app_id` on a box.
+- Consumes: Task 8 (`policyWriter`, `policyAttempt`), Task 9 (`policyLock`), `release.AppPolicyKey`, `apppolicy.Delete`, `s.appMutation`, `claimEnrollmentContext` (`claim_app_admission.go:189`) and `mutationAppID`'s enrollment branch (212-220).
+- Produces: `claimPolicyClearFailed` and `claimPolicyWriterRefused` (`claimOutcome`s); `type policyWriterRefused struct{ ref *doorRefusal }` (an `error`, so `createClaimAppRow` keeps its signature); `func (s *Server) clearAppPolicy(ctx context.Context, acct *Account, app string) error` = `s.appMutation(ctx, acct, app, "app-policy-clear", func(ctx) error { return apppolicy.Delete(ctx, s.cfg.Releases, acct.Sqid, app) })`, wrapped in `errAppPolicyClear` on failure; `createClaimAppRow` keeps its signature.
+- 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 never succeeds while a policy object at the key cannot be deleted, in every mode** (spec §3: "before the name is usable"; Astra plan R3-3). `createClaimAppRow` calls `clearAppPolicy` **before the row is inserted**, inside the exclusion below: with `HostClaim` off, right before the insert; with `HostClaim` on, after `EnrollNewApp` and `claimEnrollmentContext`, with the enrollment context, whose identity `mutationAppID` already answers before the row exists, so the boundary admits the app-scoped delete (Task 6), and before `withAutoAppSQL("sql.CreateApp")`. **The clear runs inside policy publication's exclusion** (Astra plan R4-1): a create that passed `claimAppWith`'s `HasAppIn` check may have paused while another create took the name and an operator granted its policy, so clearing on that stale check would delete the winner's policy. `createClaimAppRow`, in every mode and first thing, takes `s.policyLock(acct.Sqid, app)` and holds it until it returns (through the clear and the row insert), then `hold, ref := s.policyWriter(ctx)` (lazy acquisition, as a door write does; sole without a bucket); a refusal returns `policyWriterRefused{ref}`, before any enrollment, delete or insert. Then (under claims, after `EnrollNewApp` and `claimEnrollmentContext`) it rechecks `s.store.HasAppIn(acct.ID, app)`: a row → `false, nil` (`claimTaken`, nothing deleted); a lookup error → returned as the insert's error is. Then `actx, cancel, ref := s.policyAttempt(ctx, hold)` (the enrollment context under claims), and `clearAppPolicy(actx, ...)`; a refusal, or `actx` ending, counts as a failed clear. The insert follows under the same lock, with the request's (or enrollment) context. A failed clear (a store error, the boundary refusing, or the holding lost) inserts no row and returns `errAppPolicyClear`; `claimAppWith` maps it to `claimPolicyClearFailed`, and `policyWriterRefused` (via `errors.As`) to `claimPolicyWriterRefused`, returning the refusal's message as its second value (documented on `claimAppWith`: for that outcome the second value is the message, not a placement). The CLI create door and `/new-temporary` answer `claimPolicyWriterRefused` with 503 and that message (Task 8's `policyLeaseHeldMsg`, naming the holder, or `policyLeaseUnavailableMsg` / `policyLeaseMissingMsg`), the browser door flashes it; one console per bucket is the norm (ruling 10), so this is the second-console case only. The PUT door (Task 9 step 7) and the re-sign take the same lock, so a policy for this name can only be written once a row exists, and the recheck sees that row. An enrollment left with no row is the state an insert failure after `EnrollNewApp` leaves today, so a retry takes that existing path. The doors answer `claimPolicyClearFailed` as retryable: the CLI create door and `/new-temporary` 503 `appCreatePolicyClearMsg`, the browser door the flash `appCreatePolicyClearFlash`. A retry of the create re-runs the clear. Applies to the owner's adopt-existing case too. Task 9 step 7's `AppID` rule stays, for an object an older console's create left (spec §3 "Older consoles"); nothing here reads `app_id` on a box.
Copy (Task 15): `appCreatePolicyClearMsg` = `an earlier app's settings under this name could not be cleared, so nothing was created. Try again`; `appCreatePolicyClearFlash` = `Could not clear an earlier app's settings under this name, so nothing was created. Please try again.`
@@ -1106,7 +1126,20 @@ func TestPurgeAppSweepsThePolicyBeforeTheMarker(t *testing.T) {
```
```go
+// regionclaim_test.go: TestClaimLostAfterResolutionWritesNothing keeps its first half and gains a second
+// (Astra plan R4-1). Fails if the clear runs on the stale existence check.
+// a second app name racy2; fx.srv.beforeClaimInsert, on its first entry only (a flag: B's own create
+// enters it again and returns at once), creates racy2 through the CLI create door (B: 200), then PUTs
+// If-None-Match:* {"egress_allow":["10.0.5.20"]} for racy2 as the ops owner (200); keep the stored bytes wB
+// A's POST for racy2 (paused at that seam, after its HasAppIn check) -> 409 taken
+// the object at release.AppPolicyKey(fx.sqid, "racy2") is wB byte for byte; exactly one racy2 row
+
// apipolicy_test.go
+func TestCreateIsRefusedWhileAnotherConsoleIsThePolicyWriter(t *testing.T) {
+ // cfg.Bucket "deploy", Task 8's fake leaser returning &lease.HeldError{Owner: "console-b:4242", ExpiresAt: t1}
+ // a predecessor's policy seeded at the key; CLI create -> 503 naming console-b:4242; HasAppIn false;
+ // the object unchanged; /new-temporary and the browser form: the same refusal, no row
+}
func TestCreatingAnAppClearsAStalePolicy(t *testing.T) {
// for each mode: newConsoleFixture (claims off) and newClaimCase (claims on)
// seed a policy at the key of a name no app holds; create the app through the CLI create door
@@ -1126,9 +1159,9 @@ func TestCreateIsRefusedWhileAPredecessorsPolicyLingers(t *testing.T) {
}
```
-- [ ] **Step 2: Run, fail:** `go test ./internal/release -run 'TestPurgeApp' && go test ./internal/console -run 'TestCreatingAnAppClearsAStalePolicy|TestCreateIsRefusedWhileAPredecessorsPolicyLingers'`.
+- [ ] **Step 2: Run, fail:** `go test ./internal/release -run 'TestPurgeApp' && go test ./internal/console -run 'TestCreatingAnAppClearsAStalePolicy|TestCreateIsRefusedWhileAPredecessorsPolicyLingers|TestCreateIsRefusedWhileAnotherConsoleIsThePolicyWriter|TestClaimLostAfterResolutionWritesNothing'`.
- [ ] **Step 3: Implement.**
-- [ ] **Step 4: Run, pass**, plus `go test ./internal/console -run 'TestPurge|TestAppCreate|TestClaimApp|TestClaimMutationStaleConsoleCannotMutateRecreatedApp|TestTemporary|TestPolicyPutStartsEmpty'` and `go test ./internal/apppolicy -run TestPolicyWriters`.
+- [ ] **Step 4: Run, pass**, plus `go test ./internal/console -run 'TestPurge|TestAppCreate|TestClaimApp|TestClaimMutationStaleConsoleCannotMutateRecreatedApp|TestTemporary|TestPolicyPutStartsEmpty|TestClaimConsoleMutationInvariant'` and the tests of each fixture changed above and `go test ./internal/apppolicy -run TestPolicyWriters`.
- [ ] **Step 5: Commit** `purge and create clear the app policy (#328, #329)`.
---
@@ -1271,7 +1304,7 @@ The flow is AGENTS.md "The census: an approval, not a decision" (merged #369). `
- 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 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 four lease refusals (`policyLeaseHeldMsg`, `policyLeaseUnavailableMsg`, `policyLeaseMissingMsg`, `policyLeaseLostMsg`); Task 11's create refusals (`appCreatePolicyClearMsg`, `appCreatePolicyClearFlash`); 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 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 four lease refusals (`policyLeaseHeldMsg`, `policyLeaseUnavailableMsg`, `policyLeaseMissingMsg`, `policyLeaseLostMsg`); Task 11's create refusals (`appCreatePolicyClearMsg`, `appCreatePolicyClearFlash`, and Task 8's lease refusals, which a create now also shows, the browser as a flash); 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|TestReleasePolicyWriter|TestCreateIsRefusedWhileAPredecessorsPolicyLingers'`.
- [ ] **Step 4: Add the approved-copy test** pinning the reviewed strings; run it.
@@ -1286,6 +1319,7 @@ The flow is AGENTS.md "The census: an approval, not a decision" (merged #369). `
- **Decisions the spec did not pin:** see the report that accompanies this plan.
- **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 R3 re-check** (run after the R3 dispositions). Spec coverage: §3 "before the name is usable" → Task 11 (create refused in every mode); §3a's box budget now holds for fleet-door boxes too → Tasks 5 and 7 (the door's ETag listing, the fleet-door witness); ruling 10's one writer now holds through a lapse mid-write → Tasks 8 to 10 (`policyAttempt`). Names: `policyAttempt`, `policyAttemptWindow`, `policyLeaseLostMsg`, `PolicyLease`'s `Lost`/`SafeUntil`, `policyHolding.lease`, `policyAfterBase`, `storeETagsPath`, `serveETagListing`, `aclETagListScope`, `fleetapi`'s `ListETags`, `listSnap`, `claimPolicyClearFailed`, `clearAppPolicy`, `errAppPolicyClear`, `appCreatePolicyClearMsg`/`Flash` are each defined once and used the same way in Tasks 5, 7, 8, 9, 10, 11, 15, "Interfaces produced" and the brief. Plans 2 and 3 consume none of the changed names (plan 2 uses `policyHeld` and Task 8's fake leaser, both unchanged in use). Copy: three new strings, listed in Task 15. Placeholders: none. Proportion: five tests and two rule paragraphs added; no bodies.
+- **Astra plan R4 re-check** (run after the R4 dispositions). Spec coverage: §3 "before the name is usable" now holds against a concurrent create and grant → Task 11 (the clear inside `policyLock` and a live writer holding, with the recheck); §3's 72 h lifetime now holds for a first publication whose marker failed → Tasks 9 and 10 (`pending`); ruling 10's witnesses now reach their race → Task 10 (`policyWitnessTTL`). Names: `pending`/`AppPolicyRow.Pending`, `claimPolicyWriterRefused`, `policyWriterRefused`, `policyWitnessTTL`, `policyLapseDeadline` are each defined once and used the same way in Tasks 9, 10 and 11; `AppPolicyRowsContext`'s signature is unchanged (the brief's pin holds; plan 2's report now also sees pending rows, which is the safe side). Copy: no new strings; Task 8's lease refusals now also reach the create doors, noted in Task 15. Placeholders: none. Proportion: three tests (one extended), one column, two rule paragraphs; no bodies.
- **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)
@@ -1319,3 +1353,13 @@ All four findings' citations were checked and hold (`lease.go:234-251`, `storedo
| R3-2 (P1) a newly hosted app misses an already-written policy until the backstop | Task 7: the backstop takes `listSnap = watch.Observed()` before its listing; a new app reuses the listing's absence only if its scope's generation still equals `listSnap`'s, else one `Get` on first encounter (always on a fleet or disarmed box). The budget test's "new app" lines now say which case is free. Test: `TestAPolicyWrittenBeforeItsAppEntersScopeIsReadOnFirstEncounter` (empty listing, then policy and record for an app not yet in scope, then the first pass that hosts it reads the policy). |
| R3-3 (P1) failed create-time cleanup lets a recreated app inherit its predecessor's size | Task 11: `createClaimAppRow` clears the key before inserting the row in every mode; under claims it does so in the enrollment context, which `mutationAppID` already resolves before the row exists. A failure inserts nothing and answers `claimPolicyClearFailed` (503 `appCreatePolicyClearMsg`, browser flash `appCreatePolicyClearFlash`, both in Task 15); a retry re-runs the clear. Task 9's `TestPolicyPutStartsEmptyOverAnotherIncarnationsPolicy` now gets its lingering object from an older console's create, since a failed clear no longer lets the create stand. Test: `TestCreateIsRefusedWhileAPredecessorsPolicyLingers` (both modes: failed clear, refused create, no instance, no row; then a clean create and provision at the build's size with no allow). |
| R3-4 (P2) fleet-door reads exceed the request budget | Task 5: the store door gains `GET /store/etags`, read-only and policy-only, mapping `control/app-policy/` to the box's own `control/app-policy/<sqid>/` and re-checking each key with `appPolicyRead`; `fleetapi` implements `release.ETagLister` over it (404 → `release.ErrRefused`). Task 7: fleet boxes list like every other box; a refused listing (an older edge) marks the apps unreadable and completes the backstop; the one-`Get`-per-app fallback is left for stores with no `ETagLister` (no production store). Tests: `TestStoreDoorListsOnlyItsOwnPolicyETags`, `TestPolicyETagsThroughTheDoor`, `TestARefusedListingIsUnreadableAndWaitsForTheNextBackstop`, and `TestFleetBoxPolicyReadsKeepTheRequestBudget`, counted at the backing store behind the real door: at most 48 LISTs and 0 GETs on a quiet day, 2 GETs per live policy on a day with two re-signs (§3a unchanged). |
+
+## Review dispositions (Astra plan R4)
+
+All three findings' citations were checked against the code and hold: `server.go:4253` (`HasAppIn`) runs before the `beforeClaimInsert` seam (`server.go:4338`) and `createClaimAppRow` (`claim_app_enroll.go:16-44`), with no recheck before the clear; `regionclaim_test.go:187` is that seam's lost-race test; `internal/lease/health.go:28-33,53` gives a 3 s TTL a 0.3 s margin, so `SafeUntil` is 2.7 s ahead, below `policyAttemptWindow`.
+
+| Finding | What changed |
+|---|---|
+| R4-1 (P1) a losing create deletes the winner's policy | Task 11: `createClaimAppRow` takes `policyLock` first and holds it through the clear and the insert, takes the writer holding lazily (`policyWriter`; a refusal answers the create with Task 8's lease refusal, outcome `claimPolicyWriterRefused`), rechecks `HasAppIn` inside the lock (a row → `claimTaken`, nothing deleted), and clears under `policyAttempt`. Fixtures with a bucket get Task 8's fake leaser. Tests: `TestClaimLostAfterResolutionWritesNothing` extended (A pauses after its check, B creates and an operator grants, A resumes: 409, B's bytes stand), `TestCreateIsRefusedWhileAnotherConsoleIsThePolicyWriter`. |
+| R4-2 (P1) a first publication whose marker failed leaves re-signing | Task 9: `app_policy_revisions.pending` (`AppPolicyRow.Pending`), set by every record and re-sign, cleared only by a confirmed publication, an adoption or a read that found the object absent; `AppPolicyRowsContext` lists synced or pending rows. Task 10: candidates are those rows on every pass; absence clears `pending`. Tests: `TestResignPicksUpAFirstPublicationWhoseSyncMarkFailed`, the pending lines in `TestAppPolicyRevisionsAreNeverReused`. |
+| R4-3 (P2) the real lease witnesses refuse their first write | Task 10: `policyWitnessTTL = 30 s` (`SafeUntil` 15 to 25 s ahead, always above the 10 s window) with a guard line that fails at a TTL that cannot admit an attempt, and `policyLapseDeadline = 45 s`; production `policyAttempt` checks unchanged; the weights refresh is now required. Tests: `TestPolicyWriterLeaseIsOneConsolePerBucket`, `TestALapsedWriterCannotOverwriteTheNextWritersRevoke` (their first 200 fails at 3 s). |
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R4.md b/docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R4.md
new file mode 100644
index 000000000..228d29ddb
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R4.md
@@ -0,0 +1,12 @@
+R3-1 — PARTLY: publication is now bound to the holding, but the two-console witness cannot reach its race (finding 3).
+R3-2 — RESOLVED: first-encounter reads check the generation captured before the listing.
+R3-3 — RESOLVED: failed cleanup prevents creation in both modes.
+R3-4 — RESOLVED: fleet boxes receive account-scoped ETag listings and a real-door budget witness.
+
+1. **P1 — Plan 1, Task 11: a losing create can delete the winner’s policy.** [Foundation:1092](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:1092) clears the policy before attempting the insert. The existence check occurs earlier at [server.go:4253](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/internal/console/server.go:4253); the insert detects competing creation only afterwards. Pause create A after that check, let B create the app and an operator grant its policy, then resume A: A deletes B’s policy and returns “taken.” The existing [lost-race test:187](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/internal/console/regionclaim_test.go:187) already provides the insertion seam. **Smallest fix:** serialize same-app creation and policy publication, recheck absence inside that exclusion, and retain it through cleanup and insertion. Extend the lost-race test to assert that the winner’s policy survives.
+
+2. **P1 — Plan 1, Tasks 9–10: a successfully published first policy can disappear from re-signing permanently.** [Foundation:802](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:802) excludes rows with empty `synced_etag`. Yet [Foundation:824](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:824) logs a failed `SyncedAppPolicyContext` and still returns success. If the holding’s initial listing already ran, [Foundation:981](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:981) supplies no further discovery. The live policy receives no refresh and expires after 72 hours despite a healthy console; Plan 2’s report also omits it. **Smallest fix:** distinguish pending publication from confirmed absence, and include pending rows in recovery candidates. Test a first publication whose bucket write succeeds but whose sync-marker write fails, after the holding’s listing.
+
+3. **P2 — Plan 1, Tasks 8 and 10: the real lease witnesses always refuse their first write.** [Foundation:726](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:726) requires at least 10 seconds before `SafeUntil`, while both witnesses use a 3-second TTL at [Foundation:1045](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:1045). The actual [lease calculation:53](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/internal/lease/health.go:53) gives that TTL only 2.7 seconds of safe time. Their initial expected 200 is therefore 503. **Smallest fix:** use a TTL exceeding the attempt window plus its safety margin, and increase the bounded lapse-poll deadline accordingly. Keep the production holding checks enabled.
+
+REJECT
\ No newline at end of file