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