carlos / platform Public

plan: claim Plan 5 rev 6, Tasks 7-9 (review round 4: gated mail delivery, strict purge inventories, schema 0/1 domain claims)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 108ca1617c0b128edef2e80c8dbb6adad3354603 parent 0f188ce
1 file changed, +673 −46
  • docs/superpowers/plans/2026-10-05-host-lifecycle-claim-5-console-ops.md +673 −46

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-5-console-ops.md b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-5-console-ops.md
index e4f8edbb4..bce5fae97 100644
--- a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-5-console-ops.md
+++ b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-5-console-ops.md
@@ -1,6 +1,6 @@
# Host lifecycle claim, Plan 5: console operations
-Rev 5, 2026-10-07. Tasks 1 to 6 are MERGED on main at `3407d114`; `feat/hostclaim-console` continues from that merge. Tasks 7 to 9 below are one consolidated implementation patch, including the structural runtime and executable route, worker, SQL and backend censuses. This replaces the previous incomplete draft and its conflicting incremental patches.
+Rev 6, 2026-10-07. Tasks 1 to 6 are MERGED on main at `3407d114`; `feat/hostclaim-console` continues from that merge. Tasks 7 to 9 below are one consolidated implementation patch, including the structural runtime and executable route, worker, SQL and backend censuses. This replaces the previous incomplete draft and its conflicting incremental patches.
> **For agentic workers:** follow the dependency order, adding failing tests before implementation. Tasks 7 to 9 share one consolidated patch. Keep `CARLOS_HOST_CLAIM` unset. A provider is delivered only when its production caller and its evidence source are present. The code below is an implementation plan, not authorization to activate or change a deployment.
@@ -8,7 +8,7 @@ Rev 5, 2026-10-07. Tasks 1 to 6 are MERGED on main at `3407d114`; `feat/hostclai
**Spec:** `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, **rev 6.30**, especially §1.1, §4, §5.9, §6.2/6.2a, §7.3 and §8 to §10. Section 1.1 overrides the older lazy-adoption wording in §9. The Moves consumer is `origin/design-moves-any-region:docs/superpowers/plans/2026-10-06-moves-phase1-0-index.md`, rows C5/C7 and finding 18.
-**Source baseline:** amadan `origin/main` at `8e9ac1b8471dc10ba63921357e4e179307fb3f4d`. This includes Tasks 1 to 6 (`3407d114`), Moves Phase 1 (`17362eaf`) and the C7 move-intent correction (`8536d0b7`, merged as `cfdb4e12`), plus the Moves copy-gate scope correction (`e5adf329`) and inode-bound file-descriptor evidence (`8e9ac1b8`). Tasks 7 to 9 apply directly to that main snapshot. No console/floor three-way assembly is needed. The temporary source archive and all checks require no checkout, index update, commit or ref write. Plan 6b remains outside this revision's edit scope.
+**Source baseline:** amadan `origin/main` at `9cf9f18f53aac403f49ad755089c41c490b7e735`. Tasks 1 to 6 are already merged. Tasks 7 to 9 apply directly to this main snapshot, including the previously integrated Moves Phase 1 and C7 intent contracts. The temporary source archive and checks require no checkout, index update, commit or ref write. Only this plan is edited.
**Slice boundary:** Tasks 5 and 6 are BUILT as the first independently mergeable slice. They supply the frozen-backfill adoption callers, a running console recovery entry point, immutable fence verification and the pre-flip conversion. They grant no create/delete/purge/placement authority before Tasks 7 to 9 construct it, and do not activate relocation. Keep the two-box region-HA model gate in §1.1: conversion may hold at `relocating/pinning`; no installation, probe or serving transition is added here.
@@ -16,7 +16,17 @@ Rev 5, 2026-10-07. Tasks 1 to 6 are MERGED on main at `3407d114`; `feat/hostclai
**Tech stack:** Go 1.26, existing conditional `release.Store`, `hostclaim`, `hostactors`, `hostfence`, `termfence`, `park`, and existing versioned storage. No new external dependency.
-## Rev 5 review dispositions
+## Rev 6 review dispositions
+
+| Round 4 finding | Revision and failing-first regression |
+|---|---|
+| P2: mail delivery precedes admission | `mailPushPass` captures the local incarnation before reading queued payloads. `deliverAppMailEvents` checks that identity before admission and again under the held permit, then rereads the hook and queue. It holds admission across HTTP delivery and SQL settlement. The HTTP sink revalidates that permit immediately before sending and marks the effect issued. An ambiguous transport outcome retains ownership. `claimOKTransport` records sends in the same effect recorder as SES, DNS and backend calls. `TestClaimMailPushCountsOutboundEffects` requires zero foreign-owned sends and two admitted sends; `TestClaimMutationStaleConsoleCannotMutateRecreatedApp` now also drives the real worker before and after recreation, for bound and unbound stale rows, plus an old in-memory turn delayed until the same console has recreated the app. `TestClaimMailPushHoldsAdmissionThroughDelivery` checks live authority, competing-purge refusal, SQL completion and ambiguous-call retention. |
+| P2: purge omits malformed store keys or trusts invalid bodies | Enabled `StoreEnvironments` uses `StrictStoreEnvironments`, validating every listed key and body before returning an inventory. Partial listings, unreadable or disappeared listed records, unsupported schemas and app/environment mismatches refuse. Enabled `GetIn` validates schema and app identity; purge explicitly uses the strict readers in readiness, capture and teardown. `TestClaimPurgeStoreInventoryRefusesBeforeEffects` covers malformed keys, unsupported schemas and identity mismatches at both request and teardown. Reader and complete-listing tests cover disabled compatibility, duplicate keys, partial-list errors and missing or failed reads. |
+| P2: backend rejects supported legacy mail claims | `backendGuard.domainClaim` accepts exactly schemas 0 and 1 with the existing domain/account/app validation and additive JSON fields, matching the other mail readers. `TestClaimBackendLegacyDomainClaims` drives the production SES and DNS wrappers for both supported schemas and verifies invalid schema or identity causes no backend effect. |
+
+The census invariant includes outbound HTTP, SES, DNS and backend calls, even when no SQL row or bucket object changes. A refused request that has already sent a callback fails coverage. Tasks 1 to 6 remain unchanged. The four round-4 regression groups were observed failing against rev 5 before their fixes. The later held-admission and delayed-queue checks pass with the corrected code. The verification record below separates those expected failures from baseline failures. Opus reviewed these deltas, identified the delayed-queue race and additive-field inconsistency, then reviewed the queue corrections and reported no remaining P1/P2 finding within this inert scope. It also identified the availability cost of uncertain HTTP delivery: a timeout can leave the app closed. This remains an explicit activation blocker, covered by the existing requirement for external-effect quiescence and recovery. Cancelling a client request does not prove a buffered remote request has stopped.
+
+## Rev 5 review dispositions (historical)
| Round 3 finding | Code and failing-first regression |
|---|---|
@@ -76,7 +86,7 @@ The built adoption rules remain unchanged. Task 7 extends their chore metadata a
- **Ownership:** account membership, service roles, CSRF, fresh authentication, control-plane protection, production/trash checks, domain ownership and DNS policy stay in their existing order. The lifecycle claim grants no hostname ownership. Bind it to the acquisition in `domains.ClaimKey`; preserve the existing authentication and `hostHolder`/`hostClaimHolder` checks as additional checks, not atomic ownership.
- **Stop evidence:** `BeginDelete` closes new door admission everywhere through the shared claim. It retains the current executor/stamp. Queuing parking chores is not proof of stop. Plan 3/6 evidence or Plan 8's verified lost-box fence authorizes each park operation; lease expiry, an absent record and an old heartbeat do not. Plan 8's participant acknowledgement barrier precedes departure from an operation's first phase.
- **Chores:** `WriteSet` uses touched regions/boxes when complete, or their union with the complete deployment inventory otherwise. `ToParking` assigns a separate obligation ID and retirement ID to every chore and preserves pre-existing chores. `CompleteObligation` matches the chore's own op ID, including discovery chores that survive adoption. Only `FreeAfterPark` clears an ordinary parked holder after every chore finishes.
-- **Portability:** no Linux-specific code in the console coordinator. Darwin and Windows compile, with local retirement refusal supplied by Plan 4. Use the default Go cache. If it is read-only in this authoring sandbox, the brief permits `GOCACHE=/home/paulca/.cache/tmp/claude-1001/own/gocache-5r5`; delete that fallback after checks. Run focused checks serially with `nice -n 10` and `go test -p 1`.
+- **Portability:** no Linux-specific code in the console coordinator. Darwin and Windows compile, with local retirement refusal supplied by Plan 4. Use the default Go cache. If it is read-only in this authoring sandbox, the brief permits `GOCACHE=/home/paulca/.cache/tmp/claude-1001/own/gocache-5r6`; delete that fallback after checks. Run focused checks serially with `nice -n 10` and `go test -p 1`.
- **Rollout:** no flag, floor, IAM, DNS, deployment or production changes in this plan or its implementation branch. Plans 5 and 7 land inert until Plan 8. Keep below-floor behavior and activation sequencing under Plan 8; never begin a protected operation and then fall through to legacy deletion.
## Settled decisions and implementation rulings
@@ -3784,21 +3794,21 @@ Tasks 7 to 9 below are one second integration slice. Their journal, authority an
## Task 7: Structural admission and complete effect boundaries
-**Invariant:** with `HostClaim` enabled, a console app mutation can reach a bucket, SQL, versioned storage, object-store, scanner, SES or DNS sink only while holding a live permit for that exact app incarnation, under a live registration or its owning exclusive operation. Authorization, CSRF, membership and fresh-auth checks remain the door's prerequisites. A context value naming an app, route or owner is not a permit.
+**Invariant:** with `HostClaim` enabled, a console app mutation can reach a bucket, SQL, versioned storage, object-store, scanner, outbound HTTP, SES or DNS sink only while holding a live permit for that exact app incarnation, under a live registration or its owning exclusive operation. Authorization, CSRF, membership and fresh-auth checks remain the door's prerequisites. A context value naming an app, route or owner is not a permit.
Implement Tasks 7 to 9 together using the consolidated patch below. Do not replay the superseded snippets from earlier revisions on top of it. The patch is code-complete for this inert console slice; the rollout dependencies listed below are not supplied by callback placeholders.
1. Install `ControlWriter` and writer-minted start/operation handles. Canonical admission, purge journal/request and shared mail-gate keys are always refused by the ordinary bucket and versioned boundaries, even with an app permit. Only typed transitions write them. `BeginExclusive` requires open admission for a new operation; matching replay does not reopen a completed purge. Recreation is the dedicated identity/epoch transition.
2. Construct the real runtime once before serving or starting workers. Replace every configured write handle, and rebind components that cached one. Raw fields are private and named, with no embedded interface that could promote an unchecked method. Lifecycle grants bind the exact holder, operation, generation and root evidence. Unknown key families and backend identities refuse.
3. Arm SQL below `database/sql` using the driver connector, per-connection permit table and triggers. Cover prepared statements, transactions, `RETURNING`, replacement and cascades. Recheck live authority before each write and commit. Ordinary handles cannot run DDL, attach another database, alter triggers or mint a permit. Boot migrations finish before connections are published. Schema and raw-handle call-site censuses fail on additions. Administrative console DB opens also arm this gate when enabled.
-4. Route ordinary high-level app writes through `release.WithAppMutation`, while retaining the lower-level boundary as the enforcement point. The callback receives the permitted context. Nested app operations mark every enclosing operation before a raw effect. Validation before effects can settle as not applied; issued or ambiguous effects remain owned. A stale inherited permit or a stale local app row cannot acquire a successor incarnation. Resolve the local row before reading canonical admission; require an exact nonempty identity match before either reusing a permit or beginning admission. New-app enrollment is a private scoped path and never overrides a nonempty different row.
+4. Route ordinary high-level app writes through `release.WithAppMutation`, while retaining the lower-level boundary as the enforcement point. The callback receives the permitted context. Nested app operations mark every enclosing operation before a raw effect. Validation before effects can settle as not applied; issued or ambiguous effects remain owned. A stale inherited permit or a stale local app row cannot acquire a successor incarnation. Resolve the local row before reading canonical admission; require an exact nonempty identity match before either reusing a permit or beginning admission. New-app enrollment is a private scoped path and never overrides a nonempty different row. Mail-event delivery holds this admission through both `mailPushClient.Do` and its SQL settlement; the HTTP sink revalidates and marks the issued effect before sending.
5. Preserve the exact saved-observation create CAS, fixed executor, identity-bound wake and retirement protocols. Backend checks resolve durable resource ownership instead of parsing arbitrary names into authority. No holderless record or malformed ownership claim silently becomes a legacy write.
The source census pins constructors and typed-authority call sites. The installed-boundary reflection inventory catches new optional write methods. New raw clients, raw SQL seams, mux registrars or worker entry points need explicit review and matching tests; they cannot silently inherit a non-app exemption.
## Task 8: Mail, purge and observable restore
-Mail acquires the durable shared resource gate first, computes the complete transitive domain/member set needed by the proposed change and downstream policy refresh, then matches every ordinary member to this console's local app identity before admitting any member or making any SES, DNS, domain, sender or policy effect. Purge recovery uses its exact journal-bound identity. Enabled inventories are strict and complete throughout readiness, capture and teardown. Nested calls consume the preflighted set. Member operations and effects do not expire. Settle joined calls, release the shared gate by exact CAS, then release app authority. Unknown external outcomes retain both; a readback alone does not prove an old remote call cannot still land.
+Mail acquires the durable shared resource gate first, computes the complete transitive domain/member set needed by the proposed change and downstream policy refresh, then matches every ordinary member to this console's local app identity before admitting any member or making any SES, DNS, domain, sender or policy effect. Purge recovery uses its exact journal-bound identity. Enabled mail and object-store inventories are strict and complete throughout readiness, capture and teardown. Validate every store key and every record schema and app/environment identity before any destructive call; a malformed or unreadable entry blocks the whole inventory. Nested calls consume the preflighted set. Member operations and effects do not expire. Settle joined calls, release the shared gate by exact CAS, then release app authority. Unknown external outcomes retain both; a readback alone does not prove an old remote call cannot still land.
Purge reserves the app before legacy backend teardown can run, captures the complete fixed host/version census, retires exact incarnations, and preserves foreign-holder witnesses. Backend and SQL cleanup use journal-bound authority. A purge closes persistent admission before deleting its journal last. Purge-request and admission writes cannot pass through a generic app permit.
@@ -3808,7 +3818,7 @@ The disabled restore branch retains main's existing marker operation, temporary-
## Task 9: Runtime wiring and exhaustive regression
-`TestClaimConsoleMutationInvariant` takes its entries from the actual enabled mux and worker registry. Its fixture table must match every entry exactly. A new route, worker, exposed mutating method or gated SQL table fails until it has reviewed coverage. Each supported app door uses an authenticated valid request, runs under foreign exclusive ownership for every affected incarnation, then runs with admission open. The foreign case must produce no affected app effect; the open case must register and reach a real raw effect below the production boundaries. Authentication failures and no-op requests cannot satisfy mutation coverage.
+`TestClaimConsoleMutationInvariant` takes its entries from the actual enabled mux and worker registry. Its fixture table must match every entry exactly. A new route, worker, exposed mutating method or gated SQL table fails until it has reviewed coverage. Each supported app door uses an authenticated valid request, runs under foreign exclusive ownership for every affected incarnation, then runs with admission open. The foreign case must produce no affected app effect, including any outbound HTTP, SES, DNS or backend call; the open case must register and reach a real raw effect below the production boundaries. Authentication failures and no-op requests cannot satisfy mutation coverage.
Intentionally unavailable lifecycle operations use separately typed refusal fixtures: assert the precise unsupported response and zero effects with both open and foreign-owned admission. Accepted retirement work must prove its durable owned operation and phase; a generic 202 is insufficient. Read and non-app entries carry individual reasons and run with app-effect tripwires. Writer exceptions name an actual unreachable sink and its callers, rather than exempting a package or key prefix. Negative witnesses insert new ungated bucket, SQL, backend and versioned doors, omit a fixture, and substitute an authentication refusal; each is detected. The admitted witness passes.
@@ -3816,11 +3826,11 @@ Keep all workers cancellable and registered at production startup. The source mu
### Reproducible source baseline
-Archive `8e9ac1b8471dc10ba63921357e4e179307fb3f4d` from amadan `origin/main` into a disposable directory below `/home/paulca/.cache/tmp/claude-1001/`. Apply the consolidated patch below with `patch -p1`. The patch is relative to main, which already includes the built Tasks 1 to 6 and Moves Phase 1. Do not apply the older built snippets or a second console-branch overlay.
+Archive `9cf9f18f53aac403f49ad755089c41c490b7e735` from amadan `origin/main` into a disposable directory below `/home/paulca/.cache/tmp/claude-1001/`. Apply the consolidated patch below with `patch -p1`. The patch is relative to main, which already includes the built Tasks 1 to 6 and Moves Phase 1. Do not apply the older built snippets or a second console-branch overlay.
Apply the following unified patch from that main source root with `patch -p1`. It includes production wiring and all regression files; it does not depend on a discarded scratch file. The Moves contract adapters and their tests are included in this baseline patch; their activation limits are stated below. Review the result as one authority change, then land through the repository's reviewed amadan workflow with the flag unset.
-Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e`. Changed files: 266.
+Patch SHA-256: `7c7657514002c693f345ecc530a232af4f40dc96cc95ab64330531d2ad050369`. Changed files: 267.
```diff
--- a/approved_moves_copy_test.go
@@ -7360,7 +7370,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+}
--- /dev/null
+++ b/internal/console/claim_backends.go
-@@ -0,0 +1,811 @@
+@@ -0,0 +1,809 @@
+package console
+
+import (
@@ -7712,13 +7722,11 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ if err != nil {
+ return mail.DomainClaim{}, false, err
+ }
-+ dec := json.NewDecoder(bytes.NewReader(body))
-+ dec.DisallowUnknownFields()
+ var c mail.DomainClaim
-+ if err = dec.Decode(&c); err != nil {
++ if err = json.Unmarshal(body, &c); err != nil {
+ return mail.DomainClaim{}, false, hostops.ErrIdentity
+ }
-+ if c.Schema != 1 || c.Domain != domain || !release.ValidAccount(c.Account) || (c.App != "" && !release.ValidApp(c.App)) {
++ if c.Schema < 0 || c.Schema > 1 || c.Domain != domain || !release.ValidAccount(c.Account) || (c.App != "" && !release.ValidApp(c.App)) {
+ return mail.DomainClaim{}, false, hostops.ErrIdentity
+ }
+ return c, true, nil
@@ -8724,7 +8732,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+}
--- /dev/null
+++ b/internal/console/claim_incarnation_test.go
-@@ -0,0 +1,238 @@
+@@ -0,0 +1,301 @@
+package console
+
+import (
@@ -8758,6 +8766,40 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ t.Fatal(err)
+ }
+ w.materialize(t, w.b)
++ // B retains its queued callbacks while A purges and recreates the name.
++ w.b.cfg.AppsDomain = "oncarlos.com"
++ rec := &claimRecorder{}
++ priorTransport := mailPushClient.Transport
++ mailPushClient.Transport = claimOKTransport{rec: rec}
++ t.Cleanup(func() { mailPushClient.Transport = priorTransport })
++ if err := w.b.store.SetMailEventHook(w.ctx, crSqid, crApp, "/mail/events", crOwner, time.Now()); err != nil {
++ t.Fatal(err)
++ }
++ enqueue := func(notice string) {
++ t.Helper()
++ if err := w.b.store.EnqueueMailEventDelivery(w.ctx, crSqid, crApp, notice, []byte(`{}`), time.Now().Add(-time.Minute)); err != nil {
++ t.Fatal(err)
++ }
++ }
++ enqueue("before-recreation")
++ if err := w.b.mailPushPass(w.ctx, time.Now); err != nil {
++ t.Fatal(err)
++ }
++ if events := rec.since(0); len(events) != 1 {
++ t.Fatalf("valid worker did not send before recreation: %v", events)
++ }
++ enqueue("delayed-before-recreation")
++ oldHooks, err := w.b.store.activeMailEventHooks(w.ctx)
++ if err != nil || len(oldHooks) != 1 {
++ t.Fatalf("old hooks: %v %v", oldHooks, err)
++ }
++ oldHook := oldHooks[0]
++ oldHook.claimAppID = crAppID
++ oldRows, err := w.b.store.dueMailEventDeliveriesFor(w.ctx, crSqid, crApp, time.Now(), mailPushPerApp)
++ if err != nil || len(oldRows) != 1 {
++ t.Fatalf("old rows: %v %v", oldRows, err)
++ }
++
+ // B goes offline holding A's row and credentials. Stage the durable result
+ // of A's completed purge; the independent purge suite drives its chores.
+ staleToken := mintUserToken(t, w.b.store, crOwner, time.Now())
@@ -8829,6 +8871,35 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ t.Fatalf("sweep bound untrusted identity: %q %v", id, err)
+ }
+ }
++ // A queued in-memory turn cannot bind to the same console's new row.
++ w.a.cfg.AppsDomain = "oncarlos.com"
++ if err := w.a.store.SetMailEventHook(w.ctx, crSqid, crApp, "/mail/events", crOwner, time.Now()); err != nil {
++ t.Fatal(err)
++ }
++ signingKey, err := w.a.store.MailEventsSigningKey()
++ if err != nil {
++ t.Fatal(err)
++ }
++ delayedMark := rec.mark()
++ w.a.deliverAppMailEvents(w.ctx, oldHook, oldRows, signingKey, time.Now)
++ if events := rec.since(delayedMark); len(events) != 0 {
++ t.Fatalf("delayed turn sent to successor: %v", events)
++ }
++ current, err = hostops.ReadCanonicalAdmission(w.ctx, w.bucket, crSqid, crApp)
++ if err != nil {
++ t.Fatal(err)
++ }
++ mark := rec.mark()
++ if err := w.b.mailPushPass(w.ctx, time.Now); err != nil {
++ t.Fatal(err)
++ }
++ if events := rec.since(mark); len(events) != 0 {
++ t.Fatalf("stale worker sent after recreation: %v", events)
++ }
++ pending, err := w.b.store.dueMailEventDeliveriesFor(w.ctx, crSqid, crApp, time.Now(), mailPushPerApp)
++ if err != nil || len(pending) != 1 {
++ t.Fatalf("stale worker settled old queue: %v %v", pending, err)
++ }
+ for _, token := range []string{staleToken} {
+ status, _, out := rawReq(t, http.MethodPut, stale.URL+"/api/cli/apps/bab/kass/config", token, body)
+ if status != http.StatusBadGateway {
@@ -8965,7 +9036,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+}
--- /dev/null
+++ b/internal/console/claim_invariant_fixtures_test.go
-@@ -0,0 +1,1499 @@
+@@ -0,0 +1,1500 @@
+package console
+
+// claim_invariant_fixtures_test.go is the fixture table TestClaimConsoleMutationInvariant
@@ -9471,10 +9542,11 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ }
+}
+
-+// claimOKTransport answers every mail push 200 without a network.
-+type claimOKTransport struct{}
++// claimOKTransport records every outbound send before answering 200.
++type claimOKTransport struct{ rec *claimRecorder }
+
-+func (claimOKTransport) RoundTrip(r *http.Request) (*http.Response, error) {
++func (tr claimOKTransport) RoundTrip(r *http.Request) (*http.Response, error) {
++ tr.rec.add(claimRawEvent{Surface: "http", Method: r.Method, Key: r.URL.String(), Unscoped: true})
+ return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{},
+ Body: http.NoBody, Request: r}, nil
+}
@@ -10387,7 +10459,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ apps: claimRefs("bab/titogo"),
+ setup: func(t *testing.T, cx *claimCase) {
+ old := mailPushClient.Transport
-+ mailPushClient.Transport = claimOKTransport{}
++ mailPushClient.Transport = claimOKTransport{rec: cx.rec}
+ t.Cleanup(func() { mailPushClient.Transport = old })
+ cx.emailPush(t)
+ appID := cx.appIDs[claimAppRef{Account: "bab", App: "titogo"}]
@@ -13127,7 +13199,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+}
--- /dev/null
+++ b/internal/console/claim_invariant_test.go
-@@ -0,0 +1,1024 @@
+@@ -0,0 +1,1025 @@
+package console
+
+// TestClaimConsoleMutationInvariant is the runtime half of the claim
@@ -13149,7 +13221,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+// 3. A door runs twice per affected incarnation. With that incarnation
+// closed under a foreign exclusive operation, the same authenticated,
+// valid request leaves no raw effect on any affected app: no bucket key
-+// the production classifier scopes to it, no SQL row change, no backend
++// the production classifier scopes to it, no SQL row change, no outbound HTTP, SES, DNS, backend
+// or versioned call at all, no write to its admission. Then, with every
+// incarnation open, the same request must succeed, write its admission
+// (the gate saw it) and reach at least one raw app effect. A 401, 404 or
@@ -13506,7 +13578,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ case e.Err != nil:
+ say("raw %s reached a key the production classifier refuses (%v)", e, e.Err)
+ case e.Unscoped:
-+ say("raw %s reached a backend or a data bucket", e)
++ say("raw %s reached an outbound sink or a data bucket", e)
+ case len(e.Scopes) != 0:
+ say("raw %s is app-scoped (%v)", e, e.Scopes)
+ case e.NonApp == claimLifecycleRoot:
@@ -13577,7 +13649,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ case e.Err != nil:
+ say("raw %s reached a key the production classifier refuses (%v)", e, e.Err)
+ case e.Unscoped:
-+ say("raw %s reached a backend or a data bucket", e)
++ say("raw %s reached an outbound sink or a data bucket", e)
+ case e.NonApp == claimLifecycleRoot:
+ say("raw %s moved a lifecycle root while an affected app was foreign-owned", e)
+ case e.touches(refs):
@@ -13623,7 +13695,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ problems = append(problems, fmt.Sprintf("open run: no write to %s's admission, so the request never registered with the gate", primary))
+ }
+ if !effect {
-+ problems = append(problems, "open run: the request reached no raw app effect (bucket, SQL, versioned or backend)")
++ problems = append(problems, "open run: the request reached no raw app effect (bucket, SQL, versioned, HTTP, SES, DNS or backend)")
+ }
+ return problems
+}
@@ -13660,7 +13732,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+// expose. The bucket and versioned method sets are read off the INSTALLED
+// boundary by reflection, so a writer method added to it joins the list.
+func claimWriterInventory(s *Server) map[string]bool {
-+ inv := map[string]bool{}
++ inv := map[string]bool{"http/POST": true}
+ methods := func(prefix string, v any, reads ...string) {
+ rt := reflect.TypeOf(v)
+ for i := 0; i < rt.NumMethod(); i++ {
@@ -14125,6 +14197,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ for name, w := range map[string]claimWindow{
+ "bucket": {events: []claimRawEvent{{Surface: "bucket", Method: "Put", Key: appKey, Scopes: []hostops.AppScope{ref.scope()}}}},
+ "admission": {events: []claimRawEvent{{Surface: "bucket", Method: "Put", Key: admissionKeyOf("bab", "titogo"), Control: true, Scopes: []hostops.AppScope{ref.scope()}}}},
++ "http": {events: []claimRawEvent{{Surface: "http", Method: "POST", Key: "https://app.example/mail/events", Unscoped: true}}},
+ "backend": {events: []claimRawEvent{{Surface: "objstore.Provisioner", Method: "EnsureBucket", Key: "carlos-bab-titogo", Unscoped: true}}},
+ "versioned": {events: []claimRawEvent{{Surface: "versioned:data", Method: "DeleteVersion", Key: "instances/acme.go.tito.io/db", Unscoped: true}}},
+ "sql": {sqlChanged: map[claimAppRef][]string{ref: {"audit"}}},
@@ -14164,7 +14237,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+// on: OpenGated, then NewServer with HostClaim already true, then
+// ConfigureClaimRuntime. Nothing switches the flag after construction. The
+// raw bucket, the raw versioned store and the four raw backends handed to
-+// Config are recorders, so the test sees exactly what got past every
++// Config, and the outbound mail HTTP transport, are recorders. The test sees what got past every
+// boundary the production constructor installs over them, and nothing else.
+//
+// Recorders sit BELOW the boundaries. A write the boundary refused never
@@ -16874,12 +16947,12 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ }
+ // Backend inventory/readiness precedes every destructive action.
+ effects := map[string]json.RawMessage{}
-+ envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, account, app)
++ envs, err := objstore.StrictStoreEnvironments(ctx, s.cfg.Releases, account, app)
+ if err != nil {
+ return "", err
+ }
+ for _, env := range append(envs, "") {
-+ rec, _, err := objstore.GetIn(ctx, s.cfg.Releases, account, app, env)
++ rec, _, err := objstore.StrictGetIn(ctx, s.cfg.Releases, account, app, env)
+ if err != nil {
+ return "", err
+ }
@@ -17357,12 +17430,12 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ }
+ // Capture every backend reconstruction input under both gates, before
+ // the first destructive call.
-+ envs, err := objstore.StoreEnvironments(ctx, p.Controller.Store, account, app)
++ envs, err := objstore.StrictStoreEnvironments(ctx, p.Controller.Store, account, app)
+ if err != nil {
+ return err
+ }
+ for _, env := range append(envs, "") {
-+ record, _, err := objstore.GetIn(ctx, p.Controller.Store, account, app, env)
++ record, _, err := objstore.StrictGetIn(ctx, p.Controller.Store, account, app, env)
+ if err != nil {
+ return err
+ }
@@ -17413,7 +17486,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ }
+ effects := claimPurgeEffects{h: h}
+ for _, env := range append(envs, "") {
-+ rec, _, err := objstore.GetIn(ctx, p.Controller.Store, account, app, env)
++ rec, _, err := objstore.StrictGetIn(ctx, p.Controller.Store, account, app, env)
+ if err != nil {
+ return err
+ }
@@ -19322,6 +19395,307 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ claimMoveUnsupportedMsg = "instance moves are not available on this console yet: they need the host-claim Moves protocol"
+)
--- /dev/null
++++ b/internal/console/claim_rev6_test.go
+@@ -0,0 +1,298 @@
++package console
++
++import (
++ "context"
++ "errors"
++ "fmt"
++ "net/http"
++ "os"
++ "testing"
++ "time"
++
++ "github.com/carlosframework/platform/internal/dnsrec"
++ "github.com/carlosframework/platform/internal/hostops"
++ "github.com/carlosframework/platform/internal/mail"
++ "github.com/carlosframework/platform/internal/objstore"
++ "github.com/carlosframework/platform/internal/release"
++)
++
++func TestClaimPurgeStoreInventoryRefusesBeforeEffects(t *testing.T) {
++ for _, tc := range []struct{ name, key, body string }{
++ {"malformed key", "stores/bad/nested.json", `{"schema":1,"app":"titogo"}`},
++ {"unsupported schema", "stores/prod.json", `{"schema":2,"app":"titogo","environment":"prod"}`},
++ {"identity mismatch", "stores/prod.json", `{"schema":1,"app":"other","environment":"prod"}`},
++ {"default unsupported schema", "store.json", `{"schema":2,"app":"titogo"}`},
++ {"default identity mismatch", "store.json", `{"schema":1,"app":"other"}`},
++ } {
++ for _, stage := range []string{"request", "teardown"} {
++ t.Run(tc.name+"/"+stage, func(t *testing.T) {
++ cx := newClaimCase(t)
++ cx.purgeWorld(t)
++ p := cx.srv.cfg.ClaimPurge
++ ctx := t.Context()
++ const id = "strict-store-purge"
++ appID := cx.appIDs[claimAppRef{Account: "bab", App: "titogo"}]
++ var h *hostops.OperationHandle
++ if stage == "teardown" {
++ got, err := p.Request(ctx, "bab", "titogo", id, "owner", claimFxOwner)
++ if got != id || err != nil && !errors.Is(err, hostops.ErrPending) {
++ t.Fatalf("request: %s %v", got, err)
++ }
++ var err2 error
++ h, err2 = p.Authority.Control.ResumeExclusive(ctx, "bab", "titogo", appID, id, hostops.OpPurging, p.Owner)
++ if err2 != nil {
++ t.Fatal(err2)
++ }
++ j, _, err := hostops.ReadPurgeJournal(ctx, cx.rel, "bab", "titogo", id, appID)
++ if err != nil {
++ t.Fatal(err)
++ }
++ j.Phase = "claimed" // Stage fixture: host barriers are tested separately.
++ key, _ := hostops.PurgeKey("bab", "titogo")
++ putMailJSON(t, cx.rel, key, j)
++ }
++ key := release.AppPrefix("bab", "titogo") + tc.key
++ if _, err := cx.rel.Put(ctx, key, []byte(tc.body), ""); err != nil {
++ t.Fatal(err)
++ }
++ mark := cx.rec.mark()
++ var err error
++ if stage == "request" {
++ _, err = p.Request(ctx, "bab", "titogo", id, "owner", claimFxOwner)
++ journal, _ := hostops.PurgeKey("bab", "titogo")
++ if _, _, e := cx.rel.Get(ctx, journal); !errors.Is(e, os.ErrNotExist) {
++ t.Errorf("invalid inventory reserved purge: %v", e)
++ }
++ } else {
++ err = p.stage(ctx, h, "teardown", func(ctx context.Context) error {
++ scope := mail.ClaimMailScope{Account: "bab", App: "titogo", AppID: appID, Operation: id}
++ return mail.WithClaimMailResource(mail.WithClaimMailScope(ctx, scope), cx.srv.claimMailStore(), func(held context.Context) error {
++ return p.teardown(held, h, "bab", "titogo", id, appID)
++ })
++ })
++ }
++ if err == nil || errors.Is(err, hostops.ErrPending) {
++ t.Errorf("invalid inventory was not refused: %v", err)
++ }
++ for _, e := range cx.rec.since(mark) {
++ if !e.Control && (e.Unscoped || len(e.Scopes) > 0) {
++ t.Errorf("effect before validation: %v", e)
++ }
++ }
++ if _, _, e := cx.rel.Get(ctx, key); e != nil {
++ t.Errorf("invalid estate record removed: %v", e)
++ }
++ })
++ }
++ }
++}
++
++func TestClaimStoreReadersValidateEnabledMode(t *testing.T) {
++ cx := newClaimCase(t)
++ for _, tc := range []struct{ name, body string }{
++ {"schema", `{"schema":2,"app":"titogo"}`},
++ {"identity", `{"schema":1,"app":"other"}`},
++ } {
++ t.Run(tc.name, func(t *testing.T) {
++ key := objstore.Key("bab", "titogo")
++ _, tag, _ := cx.rel.Get(t.Context(), key)
++ if _, err := cx.rel.Put(t.Context(), key, []byte(tc.body), tag); err != nil {
++ t.Fatal(err)
++ }
++ if _, _, err := objstore.GetIn(t.Context(), cx.srv.cfg.Releases, "bab", "titogo", ""); err == nil {
++ t.Fatal("enabled reader accepted invalid record")
++ }
++ if _, _, err := objstore.GetIn(t.Context(), cx.rel, "bab", "titogo", ""); err != nil {
++ t.Fatalf("legacy reader changed: %v", err)
++ }
++ mark := cx.rec.mark()
++ if err := objstore.TeardownIn(t.Context(), cx.srv.cfg.Releases, cx.srv.cfg.StoreProvisioner, nil, "bab", "titogo", ""); err == nil {
++ t.Fatal("teardown accepted invalid record")
++ }
++ for _, e := range cx.rec.since(mark) {
++ if !e.Control {
++ t.Errorf("teardown effect: %v", e)
++ }
++ }
++ })
++ }
++}
++
++func TestClaimBackendLegacyDomainClaims(t *testing.T) {
++ for _, schema := range []int{0, 1, -1, 2} {
++ for _, bad := range []string{"", "domain", "account", "app"} {
++ t.Run(string(rune('a'+schema+1))+"/"+bad, func(t *testing.T) {
++ raw := release.NewDirStore(t.TempDir())
++ ctx := mailGuardPermits(t, raw, hostops.AppScope{Account: "bab", App: "titogo"})
++ w := claimTestWriter(t, raw)
++ lease, err := w.AcquireMailGate(ctx, hostops.MailGateOwner{Console: "c", Enrollment: "e", Controller: "k"}, hostops.MailGateScope{})
++ if err != nil {
++ t.Fatal(err)
++ }
++ ctx = hostops.WithMailGateLease(ctx, lease)
++ const domain = "legacy.example.com"
++ c := mail.DomainClaim{Schema: schema, Domain: domain, Account: "bab", App: "titogo", CreatedAt: time.Now()}
++ switch bad {
++ case "domain":
++ c.Domain = "other.example.com"
++ case "account":
++ c.Account = ""
++ case "app":
++ c.App = "../other"
++ }
++ putMailJSON(t, raw, mail.DomainClaimKey(domain), c)
++ mem := mail.NewMemProvisioner("1")
++ dns := dnsrec.NewMemWriter()
++ g := backendGuard{data: raw, control: w}
++ p := newClaimMailProvisioner(mem, g)
++ d := newClaimDNSWriter(dns, g)
++ _, sesErr := p.EnsureIdentity(ctx, "eu-west-1", domain, "", false)
++ dnsErr := d.EnsureRecord(ctx, dnsrec.Record{Name: "_dmarc." + domain, Type: "TXT", Values: []string{"v=DMARC1; p=none"}})
++ valid := (schema == 0 || schema == 1) && bad == ""
++ if valid {
++ if sesErr != nil || dnsErr != nil {
++ t.Fatalf("legacy supported claim: SES=%v DNS=%v", sesErr, dnsErr)
++ }
++ } else {
++ if sesErr == nil || dnsErr == nil {
++ t.Fatalf("invalid claim accepted: SES=%v DNS=%v", sesErr, dnsErr)
++ }
++ if len(mem.Identities) != 0 || len(dns.Records) != 0 {
++ t.Fatal("invalid claim reached backend")
++ }
++ }
++ })
++ }
++ }
++}
++
++// The same worker fixture checks actual HTTP delivery, not just its SQL receipt.
++func TestClaimMailPushCountsOutboundEffects(t *testing.T) {
++ cx := newClaimCase(t)
++ door := claimFixtures["worker StartMailEventPush"].door
++ door.setup(t, cx)
++ ref := door.apps[0]
++ restore := cx.foreign(t, ref)
++ w := cx.measure(t, door)
++ if problems := claimForeignProblems(ref, door.apps, w); len(problems) > 0 {
++ t.Errorf("foreign delivery: %v", problems)
++ }
++ restore()
++ w = cx.measure(t, door)
++ sends := 0
++ for _, e := range w.events {
++ if e.Surface == "http" && e.Method == http.MethodPost {
++ sends++
++ }
++ }
++ if sends != 2 {
++ t.Fatalf("open worker sent %d requests, want 2", sends)
++ }
++}
++
++type claimDeliveryTransport struct {
++ call func(*http.Request) (*http.Response, error)
++}
++
++func (tr claimDeliveryTransport) RoundTrip(r *http.Request) (*http.Response, error) {
++ return tr.call(r)
++}
++
++func TestClaimMailPushHoldsAdmissionThroughDelivery(t *testing.T) {
++ for _, ambiguous := range []bool{false, true} {
++ t.Run(fmt.Sprint(ambiguous), func(t *testing.T) {
++ cx := newClaimCase(t)
++ door := claimFixtures["worker StartMailEventPush"].door
++ door.setup(t, cx)
++ appID := cx.appIDs[door.apps[0]]
++ calls := 0
++ old := mailPushClient.Transport
++ mailPushClient.Transport = claimDeliveryTransport{call: func(r *http.Request) (*http.Response, error) {
++ calls++
++ if err := hostops.RequireAppMutation(r.Context(), cx.rel, "bab", "titogo", appID); err != nil {
++ t.Errorf("outbound call lacks permit: %v", err)
++ }
++ a, err := hostops.ReadCanonicalAdmission(r.Context(), cx.rel, "bab", "titogo")
++ if err != nil || a.Value.Op == nil || a.Value.Op.Intent["action"] != "mail-event-delivery" {
++ t.Errorf("delivery admission: %+v %v", a.Value, err)
++ }
++ _, err = cx.srv.cfg.ClaimAuthority.Control.BeginExclusive(r.Context(), "bab", "titogo", appID, hostops.AppOperation{ID: "competing", Kind: hostops.OpPurging, AppID: appID, Phase: "reserved"}, claimTestOwner)
++ if err == nil {
++ t.Error("purge entered while HTTP delivery was in flight")
++ }
++ if ambiguous {
++ return nil, errors.New("lost response")
++ }
++ return old.RoundTrip(r)
++ }}
++ t.Cleanup(func() { mailPushClient.Transport = old })
++ if err := cx.srv.mailPushPass(t.Context(), time.Now); err != nil {
++ t.Fatal(err)
++ }
++ a, err := hostops.ReadCanonicalAdmission(t.Context(), cx.rel, "bab", "titogo")
++ if err != nil {
++ t.Fatal(err)
++ }
++ if ambiguous {
++ if calls != 1 || a.Value.Op == nil || a.Value.State != "closed" {
++ t.Fatalf("ambiguous call released: calls=%d admission=%+v", calls, a.Value)
++ }
++ } else {
++ if calls != 2 || a.Value.Op != nil || a.Value.State != "open" {
++ t.Fatalf("joined delivery unsettled: calls=%d admission=%+v", calls, a.Value)
++ }
++ rows, err := cx.st.dueMailEventDeliveriesFor(t.Context(), "bab", "titogo", time.Now(), mailPushPerApp)
++ if err != nil || len(rows) != 0 {
++ t.Fatalf("delivery did not settle SQL: %v %v", rows, err)
++ }
++ }
++ })
++ }
++}
++
++type claimStoreInventoryFault struct {
++ release.Store
++ list []string
++ listErr, errorRead error
++}
++
++func (s claimStoreInventoryFault) List(context.Context, string) ([]string, error) {
++ return s.list, s.listErr
++}
++func (s claimStoreInventoryFault) Get(ctx context.Context, key string) ([]byte, string, error) {
++ if s.errorRead != nil {
++ return nil, "", s.errorRead
++ }
++ return s.Store.Get(ctx, key)
++}
++func TestClaimStoreInventoryCompleteListing(t *testing.T) {
++ raw := release.NewDirStore(t.TempDir())
++ key := objstore.KeyIn("bab", "titogo", "prod")
++ putMailJSON(t, raw, key, objstore.Record{Schema: 1, App: "titogo", Environment: "prod"})
++ sentinel := errors.New("partial listing")
++ for _, tc := range []struct {
++ name string
++ keys []string
++ listErr, readErr error
++ want bool
++ }{
++ {"valid duplicate", []string{key, key}, nil, nil, true},
++ {"partial", []string{key}, sentinel, nil, false},
++ {"missing listed", []string{key}, nil, os.ErrNotExist, false},
++ {"failed read", []string{key}, nil, sentinel, false},
++ {"foreign prefix", []string{"apps/zed/other/stores/prod.json"}, nil, nil, false},
++ {"stray", []string{key, "apps/bab/titogo/stores/notes.txt"}, nil, nil, false},
++ } {
++ t.Run(tc.name, func(t *testing.T) {
++ s := claimStoreInventoryFault{Store: raw, list: tc.keys, listErr: tc.listErr, errorRead: tc.readErr}
++ got, err := objstore.StrictStoreEnvironments(t.Context(), s, "bab", "titogo")
++ if tc.want {
++ if err != nil || len(got) != 1 || got[0] != "prod" {
++ t.Fatalf("%v %v", got, err)
++ }
++ } else if err == nil || got != nil {
++ t.Fatalf("incomplete inventory accepted: %v %v", got, err)
++ }
++ })
++ }
++}
+--- /dev/null
+++ b/internal/console/claim_route_writes.go
@@ -0,0 +1,78 @@
+package console
@@ -21026,7 +21400,15 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
// rather than waiting out the hour.
--- a/internal/console/mailpush.go
+++ b/internal/console/mailpush.go
-@@ -298,11 +298,13 @@
+@@ -68,6 +68,7 @@
+ "sync"
+ "time"
+
++ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/mail"
+ )
+
+@@ -298,16 +299,19 @@
// lifts a suspension and forgets the run of failures behind it: turning it
// on again is how a member says the handler is there now.
func (s *Store) SetMailEventHook(ctx context.Context, acct, app, path, by string, now time.Time) error {
@@ -21043,7 +21425,13 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
}
const mailEventHookColumns = `account, app, path, set_by, set_at, failing_since, suspended_at, suspended_why`
-@@ -365,7 +367,15 @@
+
+ type mailEventHookRow struct {
++ claimAppID string // Captured before reading queued payloads; never rebound by a delayed worker.
+ Account, App string
+ MailEventHook
+ }
+@@ -365,7 +369,15 @@
// running; and a run that has lasted mailPushSuspendAfter suspends the push
// and gives up on its queue, in one transaction. It answers whether the push
// is now suspended.
@@ -21060,7 +21448,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
switch {
case landed:
if h.FailingSince.IsZero() {
-@@ -412,18 +422,28 @@
+@@ -412,18 +424,28 @@
if h.FailingSince.IsZero() {
return nil
}
@@ -21093,7 +21481,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, err
-@@ -446,9 +466,11 @@
+@@ -446,9 +468,11 @@
// EnqueueMailEventDelivery queues one notification for one app. A notice
// already queued for the app queues nothing.
func (s *Store) EnqueueMailEventDelivery(ctx context.Context, acct, app, notice string, payload []byte, now time.Time) error {
@@ -21107,7 +21495,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
}
type mailEventDelivery struct {
-@@ -493,12 +515,22 @@
+@@ -493,12 +517,22 @@
// after the backoff, or given up on once the backoff is spent. A row already
// given up on (the push was turned off while this attempt was in flight)
// stays given up on: it is not counted twice, and it is not revived.
@@ -21132,7 +21520,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
return err
}
if len(failure) > 300 {
-@@ -506,13 +538,13 @@
+@@ -506,13 +540,13 @@
}
if attempts > len(mailPushBackoff) {
_, err := s.db.ExecContext(ctx, `UPDATE mail_event_deliveries
@@ -21150,7 +21538,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
return err
}
-@@ -520,9 +552,11 @@
+@@ -520,9 +554,11 @@
// app, in one statement. The reason goes in abandoned_why, never over
// last_error: what the app last answered stays what it last answered.
func (s *Store) abandonMailEventDeliveries(ctx context.Context, acct, app, reason string, now time.Time) error {
@@ -21164,7 +21552,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
}
// pruneMailEventDeliveries drops every queued row past the retention window,
-@@ -535,6 +569,11 @@
+@@ -535,6 +571,11 @@
}
if !s.mailPushPruned.CompareAndSwap(last, now.Unix()) {
return nil
@@ -21176,6 +21564,136 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM mail_event_deliveries WHERE created_at < ?`, now.Add(-mailEventsRetention).Unix()); err != nil {
// Not pruned, so not stamped: the next pass tries again.
+@@ -678,6 +719,12 @@
+ if ctx.Err() != nil {
+ break
+ }
++ if s.cfg.HostClaim {
++ h.claimAppID, err = s.mutationAppID(ctx, h.Account, h.App)
++ if err != nil {
++ continue
++ }
++ }
+ rows, err := s.store.dueMailEventDeliveriesFor(ctx, h.Account, h.App, start, mailPushPerApp)
+ if err != nil {
+ log.Printf("console: mail push: reading the queue for %s/%s: %v", h.Account, h.App, err)
+@@ -718,6 +765,54 @@
+ // began, has all of its rows given up on at once — there is nothing to wait
+ // for.
+ func (s *Server) deliverAppMailEvents(ctx context.Context, h mailEventHookRow, rows []mailEventDelivery, key ed25519.PrivateKey, clock func() time.Time) {
++ if !s.cfg.HostClaim {
++ _ = s.deliverAppMailEventsHeld(ctx, h, rows, key, clock)
++ return
++ }
++ acct, err := s.store.AccountBySqid(h.Account)
++ if err == nil && acct == nil {
++ err = hostops.ErrIdentity
++ }
++ if err == nil {
++ currentID, readErr := s.mutationAppID(ctx, h.Account, h.App)
++ err = readErr
++ if err == nil && (h.claimAppID == "" || currentID != h.claimAppID) {
++ err = hostops.ErrIdentity
++ }
++ }
++ if err == nil {
++ err = s.claimAppMutation(ctx, acct, h.App, "mail-event-delivery", func(held context.Context) error {
++ if h.claimAppID == "" {
++ return hostops.ErrIdentity
++ }
++ if err := hostops.RequireAppMutation(held, s.cfg.Releases, h.Account, h.App, h.claimAppID); err != nil {
++ return err
++ }
++ // The queue may have changed while this goroutine waited for a slot.
++ hook, err := s.store.MailEventHookFor(held, h.Account, h.App)
++ if err != nil {
++ return err
++ }
++ if hook == nil || hook.Suspended() {
++ return nil
++ }
++ h.MailEventHook = *hook
++ rows, err = s.store.dueMailEventDeliveriesFor(held, h.Account, h.App, clock(), mailPushPerApp)
++ if err != nil {
++ return err
++ }
++ return s.deliverAppMailEventsHeld(held, h, rows, key, clock)
++ })
++ }
++ if err != nil {
++ log.Printf("console: mail push: %s/%s: %v", h.Account, h.App, err)
++ }
++}
++
++// The admitted context covers delivery and all SQL settlement. A transport
++// failure can have reached the app, so its issued effect remains owned.
++func (s *Server) deliverAppMailEventsHeld(ctx context.Context, h mailEventHookRow, rows []mailEventDelivery, key ed25519.PrivateKey, clock func() time.Time) error {
++ var result error
+ if !s.mailAppLive(h.Account, h.App) {
+ // A trashed app's name is no longer a member's live app, and while it
+ // sits in the trash its platform host is not reliably its own (the
+@@ -725,9 +820,10 @@
+ // is sent to it; a purged, renamed or moved app's rows have already
+ // been forgotten, carried or dropped.
+ if err := s.store.abandonMailEventDeliveries(ctx, h.Account, h.App, "the app is not live", clock()); err != nil {
++ result = errors.Join(result, err)
+ log.Printf("console: mail push: giving up on %s/%s: %v", h.Account, h.App, err)
+ }
+- return
++ return result
+ }
+ landed, failure := false, ""
+ for _, d := range rows {
+@@ -737,12 +833,17 @@
+ status, why, giveUp := s.deliverMailEvent(ctx, d, h.Path, key, clock)
+ if giveUp {
+ if err := s.store.abandonMailEventDeliveries(ctx, d.Account, d.App, why, clock()); err != nil {
++ result = errors.Join(result, err)
+ log.Printf("console: mail push: giving up on %s/%s: %v", d.Account, d.App, err)
+ }
+- return
++ return result
+ }
+ if err := s.store.recordMailEventAttempt(ctx, d, status, why, clock()); err != nil {
++ result = errors.Join(result, err)
+ log.Printf("console: mail push: recording delivery %d for %s/%s: %v", d.ID, d.Account, d.App, err)
++ }
++ if s.cfg.HostClaim && status == 0 && why != "" {
++ result = errors.Join(result, errors.New(why))
+ }
+ if why != "" {
+ failure = why
+@@ -752,11 +853,13 @@
+ }
+ suspended, err := s.store.noteMailEventTurn(ctx, h, landed, failure, clock())
+ if err != nil {
++ result = errors.Join(result, err)
+ log.Printf("console: mail push: noting the turn for %s/%s: %v", h.Account, h.App, err)
+ }
+ if suspended {
+ log.Printf("console: mail push: suspended %s/%s — nothing accepted for %s", h.Account, h.App, mailPushSuspendAfter)
+ }
++ return result
+ }
+
+ // deliverMailEvent makes one attempt, and answers the HTTP status, what went
+@@ -788,6 +891,16 @@
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "carlos-mail-events/1")
+ req.Header.Set("Carlos-Signature", signMailEvent(key, clock(), d.Payload))
++ if s.cfg.HostClaim {
++ appID, err := s.mutationAppID(ctx, d.Account, d.App)
++ if err == nil {
++ err = hostops.RequireAppMutation(ctx, s.cfg.Releases, d.Account, d.App, appID)
++ }
++ if err != nil {
++ return 0, err.Error(), false
++ }
++ hostops.MarkAppMutationIssued(ctx)
++ }
+ resp, err := mailPushClient.Do(req)
+ if err != nil {
+ // The URL is the app's own and not a secret, but the error can be
--- a/internal/console/materialize.go
+++ b/internal/console/materialize.go
@@ -278,7 +278,7 @@
@@ -41253,7 +41771,44 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+}
--- a/internal/objstore/objstore.go
+++ b/internal/objstore/objstore.go
-@@ -265,14 +265,14 @@
+@@ -180,6 +180,9 @@
+ // prefix is ours alone, so anything else under it is somebody's hand edit,
+ // and a malformed name must not become a bucket coordinate.
+ func StoreEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) {
++ if _, enabled := s.(release.AppMutationRunner); enabled {
++ return StrictStoreEnvironments(ctx, s, acct, app)
++ }
+ if !release.ValidAccount(acct) {
+ return nil, fmt.Errorf("objstore: invalid account %q", acct)
+ }
+@@ -234,6 +237,16 @@
+ // Same rule provision.GetRecord applies to a record that lies about its own
+ // identity.
+ func GetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) {
++ _, strict := s.(release.AppMutationRunner)
++ return getIn(ctx, s, acct, app, env, strict)
++}
++
++// StrictGetIn validates the record before it can supply teardown coordinates.
++func StrictGetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) {
++ return getIn(ctx, s, acct, app, env, true)
++}
++
++func getIn(ctx context.Context, s release.Store, acct, app, env string, strict bool) (*Record, string, error) {
+ if !release.ValidAccount(acct) {
+ return nil, "", fmt.Errorf("objstore: invalid account %q", acct)
+ }
+@@ -255,6 +268,9 @@
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ return nil, "", fmt.Errorf("objstore: %s: %w", key, err)
+ }
++ if strict && (rec.Schema < 0 || rec.Schema > Schema || rec.App != app) {
++ return nil, "", fmt.Errorf("%w: %s", ErrStoreInventory, key)
++ }
+ if rec.Environment != env {
+ return nil, "", fmt.Errorf("objstore: %s names environment %q, not %q — refusing to read a record that disagrees with its own key",
+ key, rec.Environment, env)
+@@ -265,14 +281,14 @@
// Put writes the record with the caller's CAS position; etag "" is
// create-only. Every writer is a read-modify-write, so a lost race is
// release.ErrCASConflict for the caller to surface, never to absorb.
@@ -41270,7 +41825,7 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
if env != "" && !appconfig.ValidEnvironmentName(env) {
return "", fmt.Errorf("objstore: invalid environment %q", env)
}
-@@ -285,3 +285,17 @@
+@@ -285,3 +301,59 @@
}
return s.Put(ctx, KeyIn(acct, app, env), append(body, '\n'), etag)
}
@@ -41288,6 +41843,48 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
+ })
+ return
+}
++
++// ErrStoreInventory means a store listing or body cannot establish its estate.
++var ErrStoreInventory = errors.New("objstore: invalid store inventory")
++
++// StrictStoreEnvironments validates every listed key and body. A listed
++// record that cannot be read is not evidence of an empty environment.
++func StrictStoreEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) {
++ if !release.ValidAccount(acct) || !release.ValidApp(app) {
++ return nil, ErrStoreInventory
++ }
++ prefix := StoresPrefix(acct, app)
++ keys, err := s.List(ctx, prefix)
++ if err != nil {
++ return nil, err
++ }
++ out := make([]string, 0, len(keys))
++ seen := map[string]bool{}
++ for _, key := range keys {
++ name, ok := strings.CutPrefix(key, prefix)
++ if !ok {
++ return nil, fmt.Errorf("%w: %s", ErrStoreInventory, key)
++ }
++ name, ok = strings.CutSuffix(name, ".json")
++ if !ok || !appconfig.ValidEnvironmentName(name) || key != KeyIn(acct, app, name) {
++ return nil, fmt.Errorf("%w: %s", ErrStoreInventory, key)
++ }
++ if seen[key] {
++ continue
++ }
++ seen[key] = true
++ rec, _, err := StrictGetIn(ctx, s, acct, app, name)
++ if err != nil {
++ return nil, err
++ }
++ if rec == nil {
++ return nil, fmt.Errorf("%w: listed record %s is absent", ErrStoreInventory, key)
++ }
++ out = append(out, name)
++ }
++ sort.Strings(out)
++ return out, nil
++}
--- a/internal/objstore/queue.go
+++ b/internal/objstore/queue.go
@@ -188,7 +188,7 @@
@@ -42578,11 +43175,16 @@ Patch SHA-256: `9d1e66512d08752945e4c23f34c3e2b975703f2613cf05d022276aee92789f7e
### Serial verification commands
-Use the default Go cache. If it is read-only in this authoring sandbox, the brief authorizes `GOCACHE=/home/paulca/.cache/tmp/claude-1001/own/gocache-5r5`; remove that fallback afterward. Keep scratch under the on-disk `$TMPDIR`. Run commands serially:
+Use the default Go cache. If it is read-only in this authoring sandbox, the brief authorizes `GOCACHE=/home/paulca/.cache/tmp/claude-1001/own/gocache-5r6`; remove that fallback afterward. Keep scratch under the on-disk `$TMPDIR`. Run commands serially:
```bash
+export GOFLAGS="-p=1 -buildvcs=false"
gofmt -l .
nice -n 10 go build -p 1 -buildvcs=false ./...
+for package in console mail provision hostops domains objstore
+do
+ nice -n 10 go test -p 1 -buildvcs=false ./internal/"$package" -count=1 -timeout=30m
+done
GOFLAGS="-p=1 -buildvcs=false" nice -n 10 make test
GOOS=darwin GOARCH=amd64 nice -n 10 go build -p 1 -buildvcs=false ./...
GOOS=windows GOARCH=amd64 nice -n 10 go build -p 1 -buildvcs=false ./...
@@ -42591,7 +43193,7 @@ GOOS=linux GOARCH=arm64 nice -n 10 go build -p 1 -buildvcs=false ./...
The repository `make test` also checks the nested `launchctx` module and uses its 30-minute test timeout. Keep test credentials and the test home isolated from the operator profile. Clear inherited `CARLOS_CONFIG` and hide the operator's `.carlos` directory in a private filesystem namespace: subprocess cwd discovery otherwise walks through the real home directory even when `HOME` is isolated. In this sandbox, `bwrap --ro-bind / / --bind "$TMPDIR" "$TMPDIR" --ro-bind "$task_config_mask" /home/paulca/.carlos --dev /dev --proc /proc -- <command>` supplies that view, where `task_config_mask` is an empty task-owned directory under `$TMPDIR`. The real config is unchanged. Do not globally set `CARLOS_CONFIG` for the full suite: configuration-discovery tests need their own fixtures to win. The socket regression fixtures use short temporary-directory prefixes under the real on-disk `TMPDIR`, keeping their assertions intact and avoiding Unix socket path limits. Do not use a symlink as `TMPDIR`: the instance isolation checks correctly reject symlinked ancestors.
-For iteration, use the claim invariant, census, constructor, restore, mail and hostops tests by name. The archive needs `-buildvcs=false` because it has no Git metadata. Reapply the embedded patch to a fresh main archive and compare its files with the tested overlay before treating this document as verified.
+For iteration, use the claim invariant, census, constructor, restore, mail and hostops tests by name. The archive needs `GOFLAGS="-p=1 -buildvcs=false"` exported because it has no Git metadata; command-line flags alone do not reach fixture binaries built by tests. Reapply the embedded patch to a fresh main archive and compare its files with the tested overlay before treating this document as verified.
### Moves Phase 1 consumption and the lost-target park stamp
@@ -42615,7 +43217,32 @@ The decision binds the holder and operation digests. Recovery rereads the claim
**Rollout limits.** The coordinator still needs a writer-side way to resume a lost handle after restart, and a reconciler for an uncertain record-decision effect before purge can drain that registration. Failed-write settlement must call `ReleaseMove` under the same handle's lifecycle grant; the lost-target stamp also needs its owning operation's issued effect context. These are explicit integration requirements, not authority minted from readable bucket IDs. A record with an empty home-region field remains unsupported by these claim-mode helpers; do not infer a region from a cloud string. The owning Moves integration must resolve that representation before supporting it. Preserve the Plan 8 signed floor for `holder.acq`, `relocating`, `park_target` and evidence readers, frozen backfill, and the two-box region-HA gate. No flag, deployment or relocation activation is part of this revision.
-## Rev 5 verification
+## Rev 6 verification
+
+The embedded patch was extracted from this document and applied with `patch -p1` to a fresh archive of amadan `origin/main` at `9cf9f18f53aac403f49ad755089c41c490b7e735`. All 267 patched files match the authoring overlay byte-for-byte. The patch SHA-256 is `7c7657514002c693f345ecc530a232af4f40dc96cc95ab64330531d2ad050369`. The remote main tip was checked again after verification and remained at that baseline. Tasks 1 to 6 are byte-for-byte unchanged.
+
+Before applying the fixes, the new purge-inventory, enabled-store-reader, legacy-backend and outbound-mail regression groups failed against rev 5. The mail recorder observed two HTTP POSTs under foreign ownership. Purge reserved work or accepted teardown despite invalid store keys or bodies. The enabled reader accepted unsupported schemas and mismatched app identities. The SES and DNS wrappers rejected a valid schema-0 claim. All these regressions pass with the corrected code. The final tests also cover stale bound and unbound consoles after recreation, an old in-memory delivery turn after same-console recreation, live admission through delivery and SQL settlement, retained uncertain HTTP effects, partial inventories and disappeared listed records.
+
+All checks ran serially under `nice -n 10`, using `-p=1`, `-count=1` and the plan's archive/credential isolation. The default Go cache was read-only, so the brief's authorized on-disk cache was used. The final complete package results were:
+
+| Package | Result | Test duration |
+|---|---|---|
+| `internal/console` | PASS | 418.079s |
+| `internal/mail` | PASS | 0.165s |
+| `internal/provision` | PASS | 3.665s |
+| `internal/hostops` | PASS | 0.271s |
+| `internal/domains` | PASS | 3.715s |
+| `internal/objstore` | PASS | 0.173s |
+
+Whole-tree `gofmt -l .` was empty. `go build ./...` and `go vet` for all six packages also passed. Exporting `GOFLAGS="-p=1 -buildvcs=false"` ensures nested fixture builds inherit the same archive settings.
+
+**Failures reproduced on unpatched main:** the first complete console run failed in `TestControlPlaneAppAllowsAPersonToPromote`, `TestControlPlaneAppRefusesAServicePromote`, `TestControlPlaneAppRefusesAServiceRollback`, `TestControlPlaneShipRefusesTheWrongArchitecture`, `TestControlPlaneShipRefusesAMismatchedEmbeddedBuild`, `TestControlPlaneShipRefusesAMissingBuildStamp`, `TestControlPlaneShipAcceptsAMatchingArtifact` and `TestControlPlaneShipRefusalNamesTheMintTrap`. Every failure was the nested fixture build's `error obtaining VCS status: exit status 128`. All eight reproduced on the unpatched main archive with the same environment. Command-line `-buildvcs=false` on the outer test was insufficient. After exporting `GOFLAGS`, the complete console package passed; these were archive-harness failures, not failures introduced by the patch.
+
+Opus reviewed the scoped authority changes and the queue-race correction. Its follow-up reported no remaining P1/P2 finding within this inert scope, while retaining the documented activation blocker for uncertain external effects. A lost HTTP response can hold the app indefinitely until a reviewed quiescence/recovery protocol exists; cancelling the client request is not proof that remote work stopped. This revision does not authorize activation.
+
+Only this plan changed in the repository. No Git write command, deployment or activation was performed. The disposable source archives, review and test scratch, and `gocache-5r6` fallback were removed after verification; all task-started processes have finished.
+
+## Rev 5 verification (historical)
The round-3 regressions were observed failing before their fixes in a disposable Rev 4 overlay, with only the test harness/API adaptations needed to compile. The stale console returned HTTP 200 and wrote the recreated app's config. Domain verification admitted closed transitive members B and C. The lenient purge inventory returned success for malformed or unsupported claims. Empty SES configuration-set removal was refused. A follow-up witness caught mail selecting the successor's canonical identity and the stale row stopping unrelated recovery; both then passed after correction. Compilation failures were corrected and were not counted as behavioral witnesses.