docs(18.1-08): complete checkout, admin sync and layer gap closure plan

This commit is contained in:
Jakub Zych
2026-10-16 12:53:44 +02:00
parent fc2a397d9b
commit 7b70e27e3c

View File

@@ -0,0 +1,340 @@
---
phase: 18.1-payment-gateway-subscriptions
plan: 08
subsystem: payments
tags: [stripe, subscriptions, checkout, idempotency, advisory-lock, cabana, bulk-actions, bfcache, gap-closure]
requires:
- phase: 18.1-payment-gateway-subscriptions
provides: "plan 07 (classes.WithSessionLock, SubscriptionLockKey, SubscriptionRetriever), plans 02-05 (pgsub core, pgstripe gateway, customer API, layer), 18.1-REVIEW.md CR-02/WR-01/WR-02/WR-03/WR-07"
provides:
- "SubscriptionPlan.CheckoutPriceID: the strict per-currency price; customer API 422 and gateway ErrNoPriceForCurrency for a currency without its own price; yen dropped from SupportsCurrency"
- "Stripe idempotency keys on customer and Checkout Session creates (10-minute window, parameter hash), Gateway.Now"
- "First-write-wins users.stripe_customer_id with re-read; ensureCustomer stores through a cancellation-free context"
- "models.TerminalStatuses, models.ScopeOpen, Service.Open, Service.CheckoutGuarded, classes.ErrAlreadySubscribed, classes.SubscriberLockKey; customer API 409 for any open subscription"
- "Framework: pact.AdminBulkAction.Detached (json:\"-\") and the detached branch of cabana.CRUDService.BulkAction (loadScoped)"
- "Plan sync (bulk and toolbar) outside any transaction; SyncPlan(ctx, nil, plan) saves with context.WithoutCancel"
- "Layer: watchPageRestore, PageRestoreTarget, SubscribeFlow.restore(), ManageFlow.restore(), guarded navigation; already_subscribed copy without \"active\""
affects: [18.1-09, horoskopia-port, pgsub-consumers, cabana-consumers]
actuals:
tokens: 10681 # chars/4 over the added lines of the realized diff (4 repos)
tasks: 4
commits: 8 # MEASURED per repo: summercms.go 2 + pgsub 3 + pgstripe 2 + layer 1 (before the SUMMARY commit)
commits: 8
plan_head_before: "summercms.go f925d6078d03be734b90835b2857b1a0645e0796; pgsub 9d83b789e5c32124a5889b42285efe7b504e44d8; pgstripe 0484d4fa33ceeffe528d240b814c16fa2c739666; layer 30046f52590d69f3e6a396ac93b37bb425aa0894"
plan_head_after: "summercms.go fc2a397d9b0fa416aff6c98eaef2c010abfb9e37; pgsub b6738d3f0246e586268caeb219e53ff6991790b2; pgstripe c9bd95308531320ea879a41e8e00da6a7b670d7b; layer 34cf926c4558446cd96c74fd94306b7727eb50ec"
tech-stack:
added: []
patterns:
- "Stripe idempotency key = kind + time window + truncated SHA-256 of the call's parameters (stdlib crypto/sha256, encoding/hex)"
- "Conditional first-write-wins UPDATE with a re-read and adoption of the stored value"
- "Per-subscriber session advisory lock around a guarded provider flow (no transaction across the provider call)"
- "Opt-in framework escape hatch (Detached) for admin actions that call external services"
- "pageshow persisted listener with a remover, wired in onMounted/onBeforeUnmount"
key-files:
created: []
modified:
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go
- ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go
- ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go
- ../sm-paymentgatewaysubscriptions-plugin/api/json.go
- ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go
- ../sm-paymentgatewaysubscriptions-plugin/classes/service.go
- ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go
- ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go
- ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go
- ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go
- ../sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go
- ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go
- ../sm-paymentgatewaysubscriptions-plugin/README.md
- ../sm-pgstripe-plugin/subscriptions/gateway.go
- ../sm-pgstripe-plugin/subscriptions/gateway_test.go
- ../sm-pgstripe-plugin/subscriptions/stripefake_test.go
- ../sm-pgstripe-plugin/subscriptions/README.md
- modules/pact/capabilities.go
- modules/cabana/crud.go
- modules/pact/README.md
- modules/cabana/README.md
- docs/backend/admin-controllers.md
- ../vue-paymentgateway-layer/app/utils/subscribe.ts
- ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue
- ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue
- ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue
- ../vue-paymentgateway-layer/i18n/locales/en.json
- ../vue-paymentgateway-layer/i18n/locales/pl.json
- ../vue-paymentgateway-layer/README.md
- .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md
key-decisions:
- "CheckoutPriceID is strict (a non-empty stripe_prices map decides alone; stripe_price_id only in the plan's own currency); StripePriceIDFor keeps PHP's lenient fallback for other readers"
- "The zero-decimal yen is refused at checkout instead of special-casing minor units (the Money JSON contract stays two-decimal)"
- "WR-03 is closed by three measures together: the per-subscriber session advisory lock (SubscriberLockKey), a Stripe idempotency key per subscriber, plan, currency and parameters, and the open-status guard; Service.Checkout stays unguarded (PHP), CheckoutGuarded is the customer-facing entry"
- "Idempotency keys use a 10-minute window and a 32-hex SHA-256 parameter hash: the window bounds a replayed Stripe failure, the hash avoids idempotency_error on changed metadata"
- "Framework addition pact.AdminBulkAction.Detached (json:\"-\"), opt-in; it needs framework tag v0.1.27 (plan 09) before the plugins build from git without the replace"
patterns-established:
- "Customer-facing provider flows of one subscriber run under classes.WithSessionLock(ctx, db, classes.SubscriberLockKey(alias, id), ...)"
- "An admin bulk action that calls an external service sets Detached and saves through its own short writes"
requirements-completed: [SC-3, D-06, D-13, D-14, D-15, CR-02, WR-01, WR-02, WR-03, WR-07]
coverage:
- id: D1
description: "CR-02: a checkout in a currency without its own gateway price is refused (customer API 422 without a gateway call, gateway ErrNoPriceForCurrency without a Stripe call); a priced currency checks out on its own price; the yen is unsupported"
requirement: CR-02
verification:
- kind: integration
ref: "sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go#TestCustomerAPISmoke/currency_without_gateway_price_is_422"
status: pass
- kind: integration
ref: "sm-pgstripe-plugin/subscriptions/gateway_test.go#TestSubscriptionGateway/the_checkout_currency_picks_the_price"
status: pass
- kind: integration
ref: "sm-pgstripe-plugin/subscriptions/gateway_test.go#TestSubscriptionGateway/supported_currencies"
status: pass
- kind: integration
ref: "sm-pgstripe-plugin/subscriptions/host_test.go#TestHostSubscriptionFlow"
status: pass
human_judgment: false
- id: D2
description: "WR-02/WR-03: idempotency keys on the customer and session creates (same key in a window, a new key for changed parameters or the next window)"
requirement: WR-02
verification:
- kind: integration
ref: "sm-pgstripe-plugin/subscriptions/gateway_test.go#TestSubscriptionGateway/idempotency_keys"
status: pass
human_judgment: false
- id: D3
description: "WR-03: checkout is 409 Already subscribed for any open subscription (past_due shown) without a gateway call; the full pgsub Postgres suite including TestServiceAPI checkout subtests and TestUserSubscribable passes with first-write-wins SetCustomerID"
requirement: WR-03
verification:
- kind: integration
ref: "sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go#TestCustomerAPISmoke/past_due_is_409"
status: pass
- kind: integration
ref: "sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go#TestCustomerAPISmoke/already_subscribed_is_409"
status: pass
- kind: integration
ref: "go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 ./..."
status: pass
human_judgment: false
- id: D4
description: "WR-02/WR-03 under true concurrency: two parallel checkouts of one subscriber (two tabs) produce one Stripe customer and at most one session; first-write-wins and the advisory lock against real Stripe"
verification: []
human_judgment: true
rationale: "No test runs two checkouts in parallel against the lock, and Stripe's idempotent replay is not exercised by the fake; a scratch check confirmed first-write-wins sequentially. Plan 09 adds unit coverage; a Stripe test-mode check belongs to acceptance"
- id: D5
description: "WR-01: sync_to_stripe is a detached bulk action and sync_all_to_stripe opens no transaction; result messages, saved ids and the framework locked path are unchanged"
requirement: WR-01
verification:
- kind: integration
ref: "sm-paymentgatewaysubscriptions-plugin/admin_actions_test.go#TestAdminSyncActions"
status: pass
- kind: integration
ref: "sm-paymentgatewaysubscriptions-plugin#TestAdminPlansSmoke, TestServiceAPI, TestSubscriptionsCoreSmoke"
status: pass
- kind: integration
ref: "summercms.go/modules/cabana#TestBulkAction* (14 top-level passes)"
status: pass
- kind: other
ref: "go vet ./...; go test -short ./...; go test -run TestDocsTree ./cmd/summer; go run ./cmd/summer docs:build --check; scripts/check-admin-openapi.sh --check"
status: pass
human_judgment: false
- id: D6
description: "WR-07: back/forward-cache restore re-enables the subscribe, manage and cancel actions; a throwing navigation resets with the generic error; listeners removed on unmount; 409 copy without \"active\""
requirement: WR-07
verification:
- kind: unit
ref: "pnpm --dir ../vue-paymentgateway-layer test (105 pass, # fail 0); bash scripts/check-phase18.1.sh layers"
status: pass
human_judgment: true
rationale: "The layer suite and a scratch node check cover watchPageRestore, restore() and the throwing navigation, but no spec mounts a component and fires pageshow; the real browser Back from Stripe is a human check"
duration: 24min
completed: 2026-10-16
status: complete
---
# Phase 18.1 Plan 08: Checkout, admin sync and layer gap closure (CR-02, WR-01, WR-02, WR-03, WR-07) Summary
**A checkout now bills only the plan's own Stripe price in the requested currency. The customer API answers the existing 422, and the gateway answers ErrNoPriceForCurrency, for a currency without its own price. Customer and Checkout Session creates carry Stripe idempotency keys, and the user's customer id is first-write-wins. A guarded checkout refuses any open subscription under a per-subscriber advisory lock. Plan sync runs outside any transaction through a new opt-in framework field, `pact.AdminBulkAction.Detached`. The layer's buttons recover after Back from Stripe.**
## Performance
- **Duration:** 24 min
- **Started:** 2026-10-16T10:28:43Z
- **Completed:** 2026-10-16T10:52:08Z
- **Tasks:** 4 of 4
- **Files modified:** 30 (13 pgsub, 4 pgstripe, 6 summercms.go, 7 layer)
## Accomplishments
- **CR-02.** `SubscriptionPlan.CheckoutPriceID(currency)` returns the plan's own price for the currency and never falls back across currencies:
- a `stripe_prices` map with at least one entry decides alone;
- without such a map, `stripe_price_id` serves only the plan's own currency.
The customer API checkout answers the existing 422 `{"currency": [...]}` without a gateway call when the price is empty. The Stripe gateway's `CreateCheckoutSession` uses the same method, so it answers `ErrNoPriceForCurrency` without a Stripe call. `StripePriceIDFor` keeps PHP's lenient fallback for other readers. The yen is no longer in `SupportsCurrency`.
- **WR-02.**
- The Stripe customer create carries an idempotency key: `pgsub-customer-<window>-<32 hex>`. The hash covers the alias, id, e-mail, name and sorted metadata, and the window is 10 minutes (`Gateway.Now`, unexported `idempotencyKey`, `idempotencyWindow`).
- The user subscriber writes `stripe_customer_id` only when none is stored. Otherwise it re-reads the stored id and adopts it.
- `ensureCustomer` stores the id through `context.WithoutCancel`. When the stored id differs from the one it created, it logs both ids at Warn and continues with the stored one.
- The `SetCustomerID` contract doc allows first-write-wins.
- **WR-03.**
- `models.TerminalStatuses` and `models.ScopeOpen` define an open subscription; `Service.Open` returns the latest one.
- `Service.CheckoutGuarded` runs under `WithSessionLock(..., SubscriberLockKey(alias, id), ...)`. It refuses with `ErrAlreadySubscribed` (naming the row id and status) when an open subscription exists, re-resolves the subscriber, then delegates to `Checkout`, which stays unguarded as in PHP.
- The customer API's early check uses `Open`. The handler calls `CheckoutGuarded`, and an `ErrAlreadySubscribed` from it is also a 409.
- The session create carries an idempotency key `pgsub-checkout-...` over the subscriber, customer, price, both URLs and the metadata.
- **WR-01 (framework).**
- `pact.AdminBulkAction.Detached bool` is tagged `json:"-"`, so neither the list schema nor the OpenAPI changes.
- `cabana.CRUDService.BulkAction` has a detached branch. It opens no transaction, loads the rows through the list scope with the new unexported `loadScoped(..., lock=false)`, and keeps the empty, partial-selection 409, `actionFailure` and `translateKey` rules.
- `lockScoped` is now `loadScoped(..., true)`, so the locked path builds the same query.
- Documented in `docs/backend/admin-controllers.md` and the pact and cabana READMEs.
- **WR-01 (pgsub).**
- `sync_to_stripe` sets `Detached: true` and no longer reads the framework transaction.
- `syncOne(ctx, svc, plan, tally)` calls `SyncPlan(ctx, nil, plan)`.
- `runSyncAll` opens no transaction, and a failed save is a failure line.
- `SyncPlan` with a nil tx saves through `s.db` with `context.WithoutCancel`.
- **WR-07.**
- `watchPageRestore(target, handler)` and `PageRestoreTarget`: a `pageshow` listener that fires only when `persisted` is true. It returns a remover, and a nil target (server rendering) gets a no-op remover.
- `SubscribeFlow.restore()` and `ManageFlow.restore()`.
- A `location.assign` that throws resets the state with `pg.subscriptions.error.generic`.
- The three components register the watch in `onMounted` and remove it in `onBeforeUnmount`. `PgMySubscription` also calls `refresh()` on a restore.
- The `already_subscribed` copy drops "active" in en and pl.
## Task Commits
sm-paymentgatewaysubscriptions-plugin (pgsub):
1. Task 1 (tracer): `f88a210` feat(18.1-08): checkout bills only the plan's own price in its currency (CR-02)
2. Task 2: `5d8ded1` feat(18.1-08): one customer and one checkout per subscriber; any open subscription blocks checkout (WR-02, WR-03)
3. Task 3: `b6738d3` feat(18.1-08): plan sync holds no transaction or row lock across Stripe calls (WR-01)
sm-pgstripe-plugin (pgstripe):
1. Task 1 (tracer): `38ad591` feat(18.1-08): Stripe checkout refuses a currency without its own price; yen dropped (CR-02)
2. Task 2: `c9bd953` feat(18.1-08): idempotency keys on the Stripe customer and Checkout Session creates (WR-02, WR-03)
summercms.go:
- Task 2 (planning docs): `f217c73` docs(18.1-08): idempotency keys and the strict checkout price in COVERAGE
- Task 3 (framework, committed before the pgsub Task 3 commit): `fc2a397` feat(18.1-08): detached admin bulk actions run outside the framework transaction
vue-paymentgateway-layer:
- Task 4: `34cf926` feat(18.1-08): subscribe and manage buttons recover after Back from Stripe; 409 copy fits any open subscription (WR-07, WR-03)
Nothing was pushed or tagged (release is plan 09).
**Framework tag needed:** the plugins use `pact.AdminBulkAction.Detached`, which exists only in summercms.go `fc2a397`. They still `require summercms v0.1.26` and build only through the `replace ../summercms.go`. Framework tag **v0.1.27** and the require bump (plan 09) are needed before the plugins can be built from git without the replace.
## Adapted existing assertions
| Repo / file | Test | Old expectation | New expectation | Why |
|---|---|---|---|---|
| pgsub `customer_api_smoke_test.go` | `TestCustomerAPISmoke` plan setup (`pro`) | `pro` had no gateway prices; the EUR checkout reached the fake gateway | `pro` gets `stripe_prices` `{pln: price_pro_pln, eur: price_pro_eur}`, so the `checkout` (EUR) and `gateway_error_is_502` (PLN) subtests still reach the gateway | CR-02: the 422 gate now needs a gateway price per currency (named in the plan) |
| pgsub `customer_api_authz_test.go` | `TestCustomerAPIAuthz` plan setup (`authz`) | plan created with no price id; "other's checkout" answered 200 | plan created with `stripe_price_id` `price_authz_pln` in its own currency (PLN); the assertions are unchanged | CR-02: without its own gateway price the checkout is now 422 |
| pgsub `fixture_plugin_test.go` | `fakeGateway.SupportsCurrency` (all users) | usd, eur, pln | usd, eur, pln, gbp (gbp is in the real gateway's list) | without gbp the new `currency_without_gateway_price_is_422` subtest would be refused by `SupportsCurrency` before the new gate and prove nothing; no test expected gbp to be refused |
| pgstripe `subscriptions/gateway_test.go` | `TestSubscriptionGateway/the checkout currency picks the price` | `eur` on the gold plan (map pln, usd; default `price_gold_default`) billed `price_gold_default` | `eur` answers `ErrNoPriceForCurrency` naming `"eur"` with no Stripe call; PLN, pln and usd unchanged | CR-02 (named in the plan) |
| pgstripe `subscriptions/gateway_test.go` | `TestSubscriptionGateway/supported currencies` | `jpy` supported | `jpy` and `JPY` in the refused list | yen dropped (named in the plan) |
No other assertion in either suite relied on the cross-currency fallback, and none expected a stored customer id to be overwritten (`TestUserSubscribable` checked only a first write).
## Smoke results (Docker host available)
- **Task 1 (tracer gate re-run, auto):**
- pgsub `TestCustomerAPISmoke`, `TestCustomerAPIAuthz`, `TestServiceAPI`: 3 top-level PASS.
- pgstripe `TestSubscriptionGateway`, `TestHostSubscriptionFlow`: 2 top-level PASS.
- Both full suites green.
- D-06: `go list -deps` of pgsub contains no stripe-go.
- **Task 2:**
- full pgsub Postgres suite green;
- pgstripe `TestSubscriptionGateway`, `TestHostSubscriptionFlow`, `TestFreshStateSmoke`: 3 top-level PASS, and the full pgstripe suite green.
- `TestCustomerAPISmoke/past_due_is_409` and `TestCustomerAPISmoke/currency_without_gateway_price_is_422` pass.
- **Task 3:**
- framework `go vet ./...`, `go test -short ./...`, `TestDocsTree`, `docs:build --check` ("no problems found") and `check-admin-openapi.sh --check` all pass;
- `TestBulkAction*`: 14 top-level PASS (see deviation 3);
- pgsub `TestAdminSyncActions`, `TestAdminPlansSmoke`, `TestServiceAPI`, `TestSubscriptionsCoreSmoke`: 4 top-level PASS;
- full pgsub and pgstripe suites green after the change.
- **Task 4:** `pnpm test`: 105 pass, `# fail 0`. `bash scripts/check-phase18.1.sh layers` passed (36 `subscriptions:` tests, minimum 36). `tsc` reports nothing for `app/utils/subscribe.ts`, and passing `window` as a `PageRestoreTarget` type-checks. `package.json` and `pnpm-lock.yaml` are unchanged against v0.2.0.
## Decisions Made
- The plan's discretionary choices were followed:
- CheckoutPriceID next to the lenient StripePriceIDFor;
- the yen dropped;
- lock plus idempotency key plus status guard for WR-03;
- a 10-minute window and a parameter hash for the keys;
- the opt-in `Detached` field for WR-01.
- `CheckoutGuarded` runs `Open`, `Subscriber` and `Checkout` on the service pool while the lock pins its connection. As in plan 07, a request holds the pinned connection plus one short second connection at a time, and the Stripe client's `LoadSettings` takes a third short one. lagoon sets no pool cap, so this is fine by default.
- The detached cabana branch passes its error through `localizeForbidden`, as the locked path does through `s.transaction`, so a `ForbiddenError` from a detached `Run` is localized identically.
- The idempotency key hashes the full checkout metadata, `user_email` included. It never leaves the process except as the Stripe header and is never logged.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Adapted test fixtures outside the plan's file list**
- **Found during:** Task 1
- **Issue:** `TestCustomerAPIAuthz` created its plan without any gateway price, so its checkout became 422. The fake gateway refused gbp, so the new subtest could not tell the new gate apart from `SupportsCurrency`.
- **Fix:** the authz plan gets its own-currency `stripe_price_id`, and the fake supports gbp (see the table above).
- **Files modified:** pgsub `customer_api_authz_test.go`, `fixture_plugin_test.go`
- **Commit:** `f88a210`
**2. [Rule 2 - Missing verification, additive] Idempotency key smoke**
- **Found during:** Task 2
- **Issue:** the Stripe fake did not keep request headers, so nothing showed that the keys reach Stripe or behave per window.
- **Fix:** `fakeRequest.Idempotency` records the `Idempotency-Key` header, and the new subtest `TestSubscriptionGateway/idempotency keys` checks:
- the format;
- the same key for a repeated checkout;
- a new key for changed parameters and for the next window;
- the customer key prefix.
- **Files modified:** pgstripe `subscriptions/stripefake_test.go`, `subscriptions/gateway_test.go`
- **Commit:** `c9bd953`
**3. [Plan count] 14 framework TestBulkAction passes, not 13**
- **Found during:** Task 3
- **Issue:** Task 3's verify expects exactly 13 `--- PASS: TestBulkAction` lines. `-run '^TestBulkAction'` also matches the existing `TestBulkActionBodyCap` in `phase121_actions_test.go`, which a previous phase added. The plan counted only the 13 functions of `phase121_bulk_test.go`.
- **Fix:** none needed. All 14 pass and the locked path is unchanged. Recorded here.
**4. [Acceptance grep] Comment rewording in subscriptionplans.go**
- `grep -n "Detached:"` must print one line. The `AdminBulkActions` doc comment said "is Detached:", so it was reworded to "runs detached" before the commit.
---
**Total deviations:** 2 auto-fixed (1 blocking, 1 additive verification) plus 2 count or cosmetic notes. **Impact:**
- no public route, event, table, column, config key or customer API response shape changed;
- the new refusals reuse the existing 409 and 422 bodies;
- non-detached bulk actions behave as before;
- `contracts.Gateway` keeps its methods.
## Issues Encountered
- Cross-repository ordering: pgstripe resolves pgsub through a `replace` onto the working tree. Each pgsub commit was tested together with the matching pgstripe change, then the two were committed back to back. Task 1's pgsub commit does not change `StripePriceIDFor`, so pgstripe stays green in between.
- Task 3 order: the framework commit `fc2a397` came before the pgsub commit `b6738d3`, because pgsub uses the new field through the replace.
## Known Stubs
None.
## Threat Flags
None. No new endpoint, auth path, file access or trust-boundary schema change. The `Detached` branch keeps the list scope, the partial-selection rule and the permission checks (T-18.1-45).
## User Setup Required
None.
## Next Phase Readiness
- Ready for 18.1-09 (gap closure 3 of 3: unit coverage and release). Plan 09 should:
- tag framework v0.1.27 and bump the plugins' `require`;
- cover with unit tests `CheckoutPriceID`, `Open`/`ScopeOpen`, `CheckoutGuarded` under concurrency, first-write-wins `SetCustomerID`, the cabana detached branch, `idempotencyKey` and `watchPageRestore`/`restore()` in a mounted component;
- release layer 0.2.1.
- No blockers.
---
*Phase: 18.1-payment-gateway-subscriptions*
*Completed: 2026-10-16*
## Self-Check: PASSED
All 8 task commits found (pgsub f88a210 5d8ded1 b6738d3, pgstripe 38ad591 c9bd953, summercms.go f217c73 fc2a397, layer 34cf926); the key modified files exist; every acceptance criterion of Tasks 1-4 re-run and passing (Task 3 bulk count 14, see deviation 3); all three sibling trees clean; summercms.go has only the foreign STATE.md/state.json edits and untracked files of other sessions.