carlos / platform Public

plan 1: Astra plan review R5 (look before deleting the policy on create; purge best-effort on denied delete)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ed9b74fda39514a3284c03fb5ea8523c8cfe4968 parent 424aa4f
2 files changed, +30 −3
  • docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md +23 −3
  • docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R5.md +7 −0
diff --git a/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md b/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md
index 8d6a3850f..4dd1b12c4 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
@@ -1108,14 +1108,28 @@ func TestALapsedWriterCannotOverwriteTheNextWritersRevoke(t *testing.T) {
**Interfaces:**
- 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.
+- 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`: first a GET of `release.AppPolicyKey(acct.Sqid, app)`; **absent → return nil with no delete request** (Astra plan R5-1: deployments built from `infra/modules/region-host` deny `s3:DeleteObject` across the deployment bucket, `main.tf:669`, and `S3Store.deleteRaw` sends the request even for an absent key, `store.go:1236`, so an unconditional delete would break every create there); present → `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 (an access-denied delete included); `createClaimAppRow` keeps its signature.
+- Rules: purge deletes the policy before the marker, **best-effort on access denied** (a `s3:DeleteObject` Deny logs `release: purge %s/%s: policy object not deleted (%v); creating this name again will be refused until it is removed` and the purge continues; the create gate below is what stops inheritance), and otherwise as before (a purge that dies on any other error 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.`
+Copy (Task 15): `appCreatePolicyClearMsg` = `an earlier app's settings under this name could not be cleared, so nothing was created. Try again, or ask an operator to delete %s from the deployment bucket`; `appCreatePolicyClearFlash` = `Could not clear an earlier app's settings under this name, so nothing was created. Please try again.`
- [ ] **Step 1: Write the failing tests**
```go
+// apipolicy_test.go (Astra plan R5-1)
+func TestCreateWithNoPolicySendsNoDelete(t *testing.T) {
+ // a store whose Delete always fails with an access-denied error (the region-host Deny):
+ // creating a name that never had a policy succeeds, and the store saw zero Delete calls
+}
+func TestCreateRefusedWhenAPredecessorPolicyCannotBeDeleted(t *testing.T) {
+ // the same store, with an object at AppPolicyKey: create refuses (claimPolicyClearFailed,
+ // the message names the key), no app row, the object still there
+}
+// trash_test.go
+func TestPurgeContinuesWhenThePolicyDeleteIsDenied(t *testing.T) {
+ // access-denied Delete for the policy key only: PurgeApp completes, the marker is removed,
+ // the policy object remains, and the log line names it
+}
// trash_test.go, beside TestPurgeAppSweepsTheAppsLedgers
func TestPurgeAppSweepsThePolicyBeforeTheMarker(t *testing.T) {
// seedApp bab/derp and bab/derpy; policy objects at AppPolicyKey for both
@@ -1363,3 +1377,9 @@ All three findings' citations were checked against the code and hold: `server.go
| 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). |
+
+## Review dispositions (Astra plan R5)
+
+| Finding | Change |
+|---|---|
+| R5-1 create blocked by the region-host `s3:DeleteObject` Deny | Task 11: `clearAppPolicy` GETs first and sends no delete for an absent key; a present key that cannot be deleted refuses the create, naming it; purge treats a denied policy delete as logged best-effort. Tests: `TestCreateWithNoPolicySendsNoDelete`, `TestCreateRefusedWhenAPredecessorPolicyCannotBeDeleted`, `TestPurgeContinuesWhenThePolicyDeleteIsDenied`. The Deny itself is an infra matter (#lease-deleteobject history) left to its own thread. |
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R5.md b/docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R5.md
new file mode 100644
index 000000000..01d2ebf3f
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-instance-policy-plans-R5.md
@@ -0,0 +1,7 @@
+R4-1 — RESOLVED: creation rechecks absence under the policy lock and writer holding, retaining the lock through cleanup and insertion.
+R4-2 — RESOLVED: pending rows remain re-sign candidates when the publication marker fails.
+R4-3 — RESOLVED: the 30-second witness TTL admits the production attempt window; lapse deadlines are extended.
+
+1. **P1 — Plan 1, Task 11: mandatory cleanup blocks ordinary app creation under the generated IAM policy.** [Foundation:1112](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/docs/superpowers/plans/2026-10-21-instance-policy-1-foundation.md:1112) requires a successful policy deletion before every app insert, including names that never had a policy. When `store_bucket_prefix` is configured, the generated role explicitly denies `s3:DeleteObject` throughout the deployment bucket at [main.tf:669](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/infra/modules/region-host/main.tf:669). [S3Store.deleteRaw:1236](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/instance-egress-sizes-328-329/internal/release/store.go:1236) sends that request even for an absent key and propagates the denial. Consequently, CLI, browser and temporary-app creation return failure on deployments using this policy. Foundation:44 acknowledges the deny only as a lease-release delay; the live manual fix does not fix deployments provisioned from this repository. **Smallest fix:** make narrowly scoped policy-deletion permission a tested rollout prerequisite, correcting the conflicting deny before enabling mandatory cleanup. Preserve refusal when an existing predecessor policy cannot be cleared, and cover creation with no policy under the generated permissions.
+
+REJECT
\ No newline at end of file