Locked Execution Plan — Audit Remediation Sprint (2026-07-02 → 2026-07-07)
Record of 2026-07-02 to 2026-07-03, kept as written. The work it plans is done.
Status: COMPLETE 2026-07-03 — four days ahead of the window. All 14 planned batches shipped as 16 merged PRs (#337–#341, #343, #345–#357; P1 split into three PRs, P2b into two): P3, P1a/b/c, P2a, P2b-a/b, P4, P14, P6, P8, P9-core, P12, P13, P10, P11, P5, P7, and the P9 query-rewrite spill. Every HIGH and MEDIUM is closed or carries a named deferral in its owning ADR (ADR-065…073); the ~41 remaining LOWs and named deferrals live in the audit report, which remains the backlog tracker. Everything below is preserved as written at lock time.
Status: LOCKED 2026-07-02. This document commits the remediation of the 2026-07-02 full-product audit — 117 verified findings (16 high / 51 medium / 50 low, deduped) — inside the Fable window, which closes end of day 2026-07-07. Five quota windows remain (Jul 3–7; Jul 2’s window was spent on the audit itself). The plan was validated by five adversarial lenses (batch/collision, three fix-design deep-dives, timeline realism); their blockers are folded in below, not appended. Hard/correctness-critical work is front-loaded; mechanical and copy work trails. Track execution against the deduped report list (16/51/50), not the raw findings JSON (19/56/42) — when a batch merges, mark every dupe-root it closes (known dupe pairs: settings :406/:407; docs/self-host.md:15 high+med; Dunning.tsx:149 ×2; UpdatePayment.tsx med+low; the two usage_events-index findings; api.ts:1623 ×2).
Coverage commitment: every HIGH and every MEDIUM is assigned to exactly one batch below. Only the 50 LOWs are backlog — minus 9 named ride-alongs that ship inside batches that already own their files. The only conscious medium-adjacent deferrals are named in Tier 4 of the cut-line.
1. Final PR Batches
Section titled “1. Final PR Batches”Ownership rule: no two concurrently-open PRs share a file. Where the collision is unavoidable (billing lane, router lane, subscription/service.go), the plan serializes with explicit merge order instead of pretending disjointness. internal/billing/preview.go is in no batch’s write set (shared public create_preview surface).
Migration numbering: next free number on this branch is 0125. P9 (×3), P2b (×1), P11 (×1) claim their five numbers together on Day 1 across git worktree list + git branch -a + origin/main, per the numbering rule. Drop-table downs must recreate the table.
P3 — Tenant-scoped billing run [S] — DP-BLOCKER — lands first, Day 1
Section titled “P3 — Tenant-scoped billing run [S] — DP-BLOCKER — lands first, Day 1”- Owns:
internal/billing/handler.go,internal/billing/engine.go(newRunCycleForTenant+SubscriptionReaderinterface atengine.go:363),internal/subscription/store.go,internal/subscription/postgres.go(newGetDueBillingForTenant). - Closes: High #8 (cross-tenant TxBypass sweep + other tenants’ error details,
handler.go:32-50,subscription/postgres.go:867). Ride-along low:subscription/postgres.go:896(status INdefeats the partial index) — the new tenant-scoped query must NOT clone the anti-pattern. - Design (locked):
RunCycleForTenantmirrorsRunCycleForClock(engine.go:1454); unscopedRunCyclestays scheduler-only.GetDueBillingForTenantusesTxTenant(RLS gives tenant+livemode;postgres.go:140-179fails loudly without ctx livemode), keepstest_clock_id IS NULL+ status filters,FOR UPDATE SKIP LOCKED. Handler: 403 fail-closed when tenant scope is absent (platform keys have empty TenantID — no unscoped fall-through); drain the tenant’s due set until empty with a bounded pass count (single batchSize=50 pass silently under-bills); return sanitized per-sub errors (sub ID + error class; full detail to slog — even tenant-own raw errors leak pq constraint names/Stripe internals). Do NOT avoid engine.go via type-assertion ofe.subs— that’s a silent-capability check, banned by the no-silent-fallback rule. - DoD: two-tenant real-Postgres isolation test (B’s subs untouched; B’s IDs/errors absent from A’s 206 body); clock-pinned sub excluded; test-mode key bills only test-mode subs; concurrent scheduler
RunCycle+ manualRunCycleForTenanton the same sub → exactly one invoice per period (playbook collision test); mutation-verify by reverting the tenant filter. CHANGELOG + MANUAL_TEST flow for POST /v1/billing/run.
P1 — Threshold hardening [M, split P1a/P1b] — DP-BLOCKER — Day 1–2, after P3
Section titled “P1 — Threshold hardening [M, split P1a/P1b] — DP-BLOCKER — Day 1–2, after P3”- Owns:
internal/billing/threshold_scan.go,internal/billing/engine.go(addMeterInterval~:2402,billOnePeriod$0 gate:2648),internal/subscription/postgres.go(ListWithThresholds:1624/ListWithThresholdsForClock:1675cursor params).preview.goread-only. - Closes: Highs #1, #2, #7; mediums at
threshold_scan.go:196(reset=true base) andengine.go:2402(max/last split-billing); low ride-alongsthreshold_scan.go:508($0 MarkPaid) and the:141/:150trialing double-gate cleanup. - Design: full fix-design note in §4.1 — the lens review changed two sub-fixes (boundary = SKIP not clamp; max/last exclusion applies only when
reset_billing_cycle=false) and mandates the intra-PR order boundary-skip → probe → drain → proration → max/last → $0-gate. - P1a (must-ship kernel): boundary skip + fire-once probe + cursor drain. P1b: reset=true proration + max/last fix + $0 MarkPaid gate (both writers). Same files, same lane, separate merges so Day 1 ends with the double-billing kernel on main regardless.
- DoD: test matrix T1–T12 (§4.1), each keyed assertion mutation-verified, real Postgres for store/collision tests; CHANGELOG per sub-fix; MANUAL_TEST threshold FLOW revised (boundary-tick behavior, $0-invoice observable); short ADR (or 0056-design-note addendum) recording three decided semantics: boundary-skip event loss, max/last-rides-to-cycle-close for reset=false, day-granularity reset=true base proration.
P2a — Customer intent: archive + hosted-invoice polling [S] — DP-BLOCKER — Day 1, parallel
Section titled “P2a — Customer intent: archive + hosted-invoice polling [S] — DP-BLOCKER — Day 1, parallel”- Owns:
internal/customer/service.go,internal/subscription/service.goCreate/UpdateItem guards only (:494fetch,:422),web-v2 .../CustomerDetail.tsx,web-v2 .../HostedInvoice.tsx. - Closes: High #4 (archive doesn’t stop billing,
CustomerDetail.tsx:439), High #6 (hosted invoice never refetches,HostedInvoice.tsx:255), mediumcustomer/service.go:371(currency re-denomination), mediumsubscription/service.go:422(archived plan accepts new subscriptions — moved here from P4, same enforcement seam as archived-customer). Low ride-alongHostedInvoice.tsx:342. - Design (locked): archive = 409-block, not auto-cancel — see §4.2. Polling design in §4.2. No adversarial panel needed (no money writes once block-not-cancel is decided); a short decision memo in the PR suffices.
- DoD: archive tests (active/trialing/paused/scheduled-cancel sub → 409 each; canceled-only → 200; unarchive → 200;
subscription.Createfor archived customer → 409; archived-plan at Create and swap-to-archived in UpdateItem → 409; mutation-verify: remove the checker call → tests fail). Polling: MANUAL_TEST flow (paid signal → poll → Pay hidden → success banner;payment_status=failed→ failure banner + Pay re-enabled; 3-min cap → honest copy, Pay stays hidden; no paid signal → no polling). CHANGELOG; short ADR for archive-blocks-not-cancels.
P2b — Checkout dedup + trial-cancel semantics [M] — DP-BLOCKER — Day 2–3, after P1 and P2a merge
Section titled “P2b — Checkout dedup + trial-cancel semantics [M] — DP-BLOCKER — Day 2–3, after P1 and P2a merge”- Owns:
internal/api/adapters.go,internal/payment/settlement.go,internal/subscription/service.go(trial/cancel surface:1545-1925),internal/billing/engine.go(trial branch:1941-1953— rebase on post-P1 main),web-v2 .../SubscriptionDetail.tsx(dialog copy — explicitly excluded from P11’s sweep), migrationcheckout_sessions(number claimed Day 1). - Closes: High #5 (double-charge via stale Checkout session + silent duplicate-success drop,
adapters.go:970-996,settlement.go:47-54); mediumssubscription/service.go:1571(trial cancel_at_period_end semantics) and:1567(cancel_at mid-period guard),settlement.go:167(receipt email states invoice TOTAL as “your payment” — pass AmountDueCents into the template). Low ride-alongadapters.go:610. - Design: full note in §4.2. Two adversarial panels required before code (Checkout dedup; trial cancel) — prompts enumerated there.
- DoD: Checkout test matrix (same-URL reuse; amount-drift → expire+remint; concurrent double-POST → exactly one open row via partial unique index, real Postgres; expire-on-settle called once iff
transitioned==true; offline MarkPaid / void / credit-to-zero each expire; different-PI duplicate success →payment.duplicate_chargeevent + audit row, same-PI → silent skip; ExpiresAt on create params; crash between row-insert and Stripe create → next POST recovers). Trial-cancel matrix (all 4 activation sites:ProcessExpiredTrials :1850,ForClock :1764,EndTrial :1697, engine:1941— schedule honored, ZERO invoices; EndTrial-with-schedule → 409; ExtendTrial → cancel follows NEW trial_end;ScheduleCancelacceptscancel_at >= trial_endfor trialing; regression: active-sub at_period_end still bills final period; immediate Cancel() of trialing sub emits no invoice). All mutation-verified. CHANGELOG; MANUAL_TEST flows (double-device pay; trial cancel-at-trial-end); one short ADR covering checkout_sessions table + customer-intent semantics.
P4 — Price-change lifecycle [M] — design Day 1, impl Day 3–4, last in billing lane
Section titled “P4 — Price-change lifecycle [M] — design Day 1, impl Day 3–4, last in billing lane”- Owns:
internal/pricing/{service,handler,override}.go,internal/billing/engine.gooverride slice (:2596-2600),internal/recipe/service.go(:428reinstall 409s — same rule_key-versioning design space). No longer touchessubscription/service.go(moved to P2a). - Closes: High #3 (overrides silently detach on version publish,
engine.go:2600+override.go:84); mediums: DELETE/deactivate override, retroactive-repricing decision (engine.go:2596), tier validation (pricing/service.go:157), recipe:428. - Design: the override-keying decision (rule_key vs carried-forward version ID) and the retroactive-vs-pinned repricing decision are made Day 1 during P1’s design session (shared engine.go context); ADR written immediately. P10’s
customer_usage.go:401slice implements against the LOCKED keying decision and cites the same ADR — this is a design dependency, not a file collision. If the repricing decision lands on “pin-at-consumption” (larger), the implementation of that one item is pre-authorized to spill to backlog; “effective-next-period” ships in-window. Adversarial panel before impl (money-path). [DECIDED 2026-07-02: (a) rule_key keying, (b) pinned via resolve-as-of-period-start — ADR-070; panel ran, consolidated protocol in §4.4; pin-at-consumption rejected, nothing spills.] - DoD: override survives a rating-rule version bump (mutation-verify by restoring version-ID lookup); DELETE override takes effect next rating; tier validation rejects the audit’s malformed cases; recipe reinstall idempotent per decided semantics. CHANGELOG + ADR (keying + repricing) + MANUAL_TEST override flow.
P5 — Email/webhook transport [M] — Day 3, parallel lane
Section titled “P5 — Email/webhook transport [M] — Day 3, parallel lane”- Owns:
internal/email/{sender,outbox,dispatcher,outbox_sender}.go,internal/webhook/{service,postgres,outbox}.go. (Notemail/templates.go— that is P13’s. Notrouter.go— see spill note.) - Closes: mediums: SMTP deadlines all 3 modes (
sender.go:787), per-row outbox marking (outbox.go:195), synchronous webhook delivery rows (webhook/service.go:417), webhook retryLeaseWindow undersizing (service.go:39-48, audit:117),outbox_sender.go:228(raw tokens at rest), webhook outbox per-row marking symmetry (webhook/outbox.go:191-246). - Design (locked by lens 4): fixes 1+2 are ONE coupled change — the 60s BatchTimeout vs 10×30s worst-case already loses marks on slow batches today, no crash needed. Shape: (1) unified
deliver()for all 3 modes,DialContext(10s)+SetDeadline(min(30s, ctx remaining)), ctx threaded; AUTH stays conditional on server advertisement + creds present (Mailpit parity); STARTTLS mode = strict fail-loud when the server can’t upgrade, withSMTP_TLS_MODE=noneset in local-dev compose/docs in the same PR (decision recorded in PR body — can break existing self-host configs). (2) claim-lease batch mirroring webhookListPendingDeliveries(webhook/postgres.go:522), lease ≈ BatchSize×30s + 60s margin (arithmetic stated in the PR description: per-send deadline × batch size + margin ≤ claim lease; BatchTimeout bounds the tick), per-row short-tx marking for dispatched/retry/DLQ. (3) webhook delivery rows: one store tx creating event + all matching delivery rows (CreateEndpointTx precedent), rows born withnext_retry_at = now()+retryLeaseWindowso the in-process goroutine owns the initial lease and the retry worker is the crash backstop — never separate per-endpoint txs after CreateEvent (new dual-write), never NULLnext_retry_at(double-POST race,postgres.go:538-542). (4) bump/fix webhook retry lease (claim ≤5 rows/tick or size the lease to the batch). Short adversarial panel pressure-testing lease arithmetic + crash points (claim/send/mark/outbox-mark). - Spill (named): the localhost validate/dial agreement (
webhook/service.go:205-236+ one-line knob wiring atrouter.go:236+.env.example) crosses into router-lane and P7-owned files — deferred to Day 5 buffer or backlog as a standalone S rider after P12 and P7 merge (design locked now:HARDCOUNT_WEBHOOK_ALLOW_LOOPBACKdefault false, consulted by both validate and dialer; exact-host match fixing thelocalhost.evil.comHasPrefix bug at:210; stored localhost endpoints keep failing closed unless flagged). The webhook event-idempotency-by-outbox-id upgrade (unique source-id column) is a schema change — deferred with this note. - DoD: test matrix includes no-AUTH server and no-STARTTLS server per mode; slow-batch test (delivered emails stay marked when a later row stalls past BatchTimeout); crash-before-attempt webhook row delivered by worker; no double-delivery while lease held; mutation-verify lease and marking. CHANGELOG; hand P7 the
.env.exampleline.
P6 — Invoice/PDF consolidation + invoice comms [S/M] — Day 2, parallel lane
Section titled “P6 — Invoice/PDF consolidation + invoice comms [S/M] — Day 2, parallel lane”- Owns:
internal/invoice/{pdf,handler,service}.go,internal/hostedinvoice/handler.go,internal/creditnote/pdf/handler touch-ups. - Closes: mediums:
pdfCurrencySymbolglobal data race (pdf.go:214),buildPDFContextconsolidation across all THREE copy-paste sites (emailedhandler.go:807, downloaded, hostedhostedinvoice/handler.go:387-455), buyer TaxID on PDFs,invoice/service.go:1091(int64 overflow → negative persisted totals),invoice/handler.go:813(wrong email amount label — same sendEmail function),hostedinvoice/handler.go:492(uncollectible invoice told to “settle via the link” — pay_enabled/copy fix). Low ride-alongcreditnote/handler.go:248. - DoD: race test (
-race) on concurrent PDF renders; one context-builder test asserting all three surfaces render identical money fields; overflow guard mutation-verified with boundary values; email amount = AmountDue not Total (negative case: partial payment). CHANGELOG.
P7 — Self-host truth [M] — Day 4
Section titled “P7 — Self-host truth [M] — Day 4”- Owns:
internal/tenant/bootstrap.go,cmd/hardcount/main.go,deploy/,docs/self-host.md,.env.example,internal/platform/migrate/migrate.go,README.md. - Closes: Highs #9 (hardcoded
hardcount_apppassword / APP_DATABASE_URL never read,main.go:382) and the self-host dead-end pair (bootstrap creates no owner user, no live key;docs/self-host.md:15high+med dupe-root); mediums: env-var reconciliation, migration-lock re-read (migrate.go:218), quick-startcp .envstep, runbook endpoints,README.md:62. Low ride-alongbootstrap.go:58(guard order — inside the exact function being rewritten,:42-125). - Core vs riders: core = bootstrap owner-user+live-key, APP_DATABASE_URL honored verbatim + fail-closed in production on the default password, migrate-lock re-read. Riders (env reconciliation, cp step, runbook sweep) attach but are pre-authorized to spill if the walkthrough eats the window.
- DoD: literal walkthrough:
docker compose up→ log in with bootstrap-printed credentials → mint a live key → default-password boot in prod mode fails loudly. Migrate-lock: re-read applied-set after lock acquisition (test: two racing appliers, one skips). Docs updated to match reality (the bar: the doc doesn’t lie). CHANGELOG; MANUAL_TEST self-host flow.
P8 — Settings hardening [S] — Day 3 (floats to any residual window)
Section titled “P8 — Settings hardening [S] — Day 3 (floats to any residual window)”- Owns:
internal/tenant/settings.go. - Closes: mediums/lows: merge-PATCH (
:406, closes the:407dupe), TZ validation (:517), net-terms 0 (:523), uppercase currency (:527, per the currency-canonical rule), invoice_prefix charset (:530). - DoD: PATCH with partial body preserves unsent fields (mutation-verify: revert to replace-semantics → test fails); invalid TZ/currency/prefix rejected with 422. Site-set enumeration in PR body; no panel (non-money-moving config). CHANGELOG.
P9 — Analytics correctness + 3 migrations [S core / M full] — Day 2 (core), numbers claimed Day 1
Section titled “P9 — Analytics correctness + 3 migrations [S core / M full] — Day 2 (core), numbers claimed Day 1”- Owns:
internal/analytics/{overview,usage,mrr_movement}.go+ exactly 3 migrations: (1) replacement trigger for 0029 — post-0102 soft delete means the trigger writes no remove rows (ongoing, unbackfillable audit-data loss); (2) indexusage_events(tenant_id, timestamp)—CREATE INDEX CONCURRENTLY, rides the applyNoTx path; (3) indexsubscription_item_changes(subscription_item_id, changed_at). P10 ships no migrations — the usage_events index lives here only, closing the duplicate index findings as one fix. - Closes: High #13 (soft-delete-blind trigger) + index mediums; query mediums: exclude removed items (
overview.go:261), currency-scope MRR (:229), add/remove as expansion/contraction (mrr_movement.go:387). - Core vs spill: core = trigger fix + 2 indexes (S). Query rewrites = second commit, spillable.
- Sequencing nicety: land P7’s
migrate.go:218fix before the CONCURRENTLY migration merges if both are in flight (P9’s migration is the first real exerciser of P7’s fix; get CI ordering right for free). - DoD: trigger test: soft-delete an item → remove row written (mutation-verify by restoring old trigger); EXPLAIN check on the two indexed queries against real Postgres; MRR movement test with a currency mix. CHANGELOG.
P10 — Batch ingest rework [S core / M full] — Day 4
Section titled “P10 — Batch ingest rework [S core / M full] — Day 4”- Owns:
internal/usage/{service,handler,postgres}.go,internal/usage/customer_usage.go. - Closes: mediums: quantity=0 billed-as-1 presence semantics (
service.go:153) — money bug, core; indexed per-row errors (service.go:224,handler.go:331) — core; memoized resolve + single-tx multi-row insert (N+1) — spillable at zero customers;customer_usage.go:401(usage view ignores overrides) — implements only after P4’s keying ADR locks (Day 1), against rule_key or carried-forward per the decision, citing the same ADR. Everything else starts immediately. - DoD: quantity=0 event meters presence, not 1 unit (mutation-verified); batch with row 3 invalid → response indexes row 3, rows 1-2 ingested per decided semantics; usage view matches engine rating for an overridden customer (negative case: non-overridden). No migrations. CHANGELOG.
P11 — FE 50-cap sweep + feature-flag deletion [S core / M full] — Day 4–5, router lane after P12
Section titled “P11 — FE 50-cap sweep + feature-flag deletion [S core / M full] — Day 4–5, router lane after P12”- Owns:
web-v2 .../CustomerCombobox.tsx, Subscriptions/Credits/Dunning/UsageEvents/PlanDetail pages,Onboarding.tsx(:152),internal/feature/, feature_flags drop-table migration (number claimed Day 1; down recreates the table + 0001/0002 seed rows),internal/api/router.go3-line unwire (import:28, wiring:555-556, mount:1282) — rebases trivially on P12. - Excludes:
SubscriptionDetail.tsx(P2b),HostedInvoice.tsx(P2a). - Feature-flag decision, made NOW at lock: DELETE. Prior audit context: subsystem inert, store is raw-pool/RLS-deferred (see
project_audit_2026_06_fixesdeferred list). No mid-window renegotiation. - Closes: High (Subscriptions picker makes the first subscription uncreatable — core, DP day-one); mediums: 50-cap pickers across the listed pages,
Onboarding.tsx:152, feature-flag decision; closesCustomerCombobox.tsx:1dead-code low by construction and theDunning.tsx:149+api.ts:1623dupe pairs. - Core vs spill: core = Subscriptions picker dedicated server-search query (S). Full sweep + flag deletion spill to Day 5/backlog.
- DoD: picker returns matches beyond the 50th customer (seeded test); flag deletion leaves no dead imports (build green); migration down recreates. CHANGELOG; MANUAL_TEST subscription-create flow.
P12 — Security hygiene [S] — Day 4, first in router lane
Section titled “P12 — Security hygiene [S] — Day 4, first in router lane”- Owns:
internal/api/router.go,internal/api/exports.go,internal/user/handler.go. - Closes: mediums: password-reset throttle (
user/handler.go:281), token logging sanitize (router.go:1382), payment-updates rate limiter (router.go:1103), exports timeout/abort + silent CSV truncation honesty (exports.go:175). Low ride-alongrouter.go:1082(bootstrap rate limit). - DoD: throttle test (Nth reset request within window → 429, mutation-verified); grep-level assertion that no token value reaches slog; truncated export signals truncation. CHANGELOG.
P13 — Customer-comms copy + token burn [S] — Day 5 (floats)
Section titled “P13 — Customer-comms copy + token burn [S] — Day 5 (floats)”- Owns:
internal/dunning/service.go,internal/email/templates.go,internal/payment/public_handler.go,web-v2 .../UpdatePayment.tsx. - Closes: mediums:
dunning/service.go:1128,public_handler.go:225(burned single-use token + copy),UpdatePayment.tsx:97branding (closes med+low dupe). Zero overlap with P5 (transport files only), P2b (settlement.go), P6 (invoice files) — verified by the collision lens. - DoD: token not burned on GET/render, only on successful submit (mutation-verified); copy reads for finance/ops per the UI-copy rule. CHANGELOG.
P14 — Credit expiry money bug [S, hard] — Day 1–2, parallel with P1
Section titled “P14 — Credit expiry money bug [S, hard] — Day 1–2, parallel with P1”- Owns:
internal/credit/{service,postgres}.go. - Closes: medium (money-invariant class)
credit/service.go:381— expiry never retires the grant block, so a backdated apply re-drains an expired grant → negative ledger balance. Low ride-along:426(cap). - Design constraints: the ledger is event-sourced and append-only — the fix appends an expiry/retirement entry (never mutates history); apply-time availability must exclude expired-grant headroom under concurrency. Adversarial panel required (money-path, concurrency reasoning — exactly what the Fable window exists for): panel prompts = concurrent apply vs expiry sweep on the same grant; backdated apply after expiry entry exists; crash between expiry detection and entry append; replay of the expiry job. [PANEL RAN 2026-07-02 — consolidated protocol in §4.4; migration 0127 claimed; implement against §4.4.]
- DoD: real-Postgres concurrent apply-vs-expire test → balance never negative (playbook collision test, mutation-verified by removing the retirement entry); backdated-apply regression. CHANGELOG; MANUAL_TEST credits flow touch-up; ADR (retirement semantics).
2. Parallelization Groups + Serialization Order
Section titled “2. Parallelization Groups + Serialization Order”LANE A (billing core — one worktree at a time, strict merge order): P3 ──→ P1a ──→ P1b ──→ P2b ──→ P4-impl why: subscription/postgres.go (P3:GetDueBilling :866 ∩ P1:ListWithThresholds :1624), engine.go (P3:RunCycleForTenant ∩ P1:2402/2648 ∩ P2b:1941 ∩ P4:2596-2600), subscription/service.go (P2a:Create guards ∩ P2b:trial-cancel :1545-1925). Edits are in disjoint functions → rebases are textually trivial, but the worktree rule forbids concurrent ownership. NEVER open the next lane worktree until the predecessor is on origin/main (a conflicting PR gets no CI).
LANE B (router.go): P12 ──→ P11 (P11's router edit is 3 lines; rebases in minutes)
DESIGN DEPENDENCIES (not file collisions): P4 keying+repricing ADR (Day 1, design only, no worktree) ──→ P10's customer_usage.go:401 slice P7 migrate.go:218 fix ──→ P9's CONCURRENTLY migration merge (ordering nicety) P5 ──→ hands P7 the .env.example line (P7 owns the file) Day 1 together: P9(×3) + P2b(×1) + P11(×1) claim migration numbers 0125+ across all branches
FULLY PARALLEL (file-disjoint, any worktree, any time): P2a, P5, P6, P7, P8, P9, P10, P13, P14 — none of these touches Lane A or Lane B files. P5 vs P13 share the email package but not files (transport vs templates.go). P2b vs P13 share the payment package but not files (settlement.go vs public_handler.go).Merge-order invariant, verbatim for every session: P3 before P1; P1 before P2b; P2a before P2b; P2b before P4; P12 before P11.
3. Fix-Design Notes — the three DP-blockers
Section titled “3. Fix-Design Notes — the three DP-blockers”These fold in every blocker/concern the design lenses raised. They are binding.
4.1 P1 — Threshold hardening
Section titled “4.1 P1 — Threshold hardening”Intra-PR order (each its own commit + mutation-verified test): (1) boundary skip → (2) fire-once probe → (3) cursor drain → (4) reset=true proration → (5) max/last → (6) $0 gate. The drain MUST NOT precede the probe — without the probe, draining amplifies the per-tick tax-calc/invoice-number burn from 50 subs to the whole crossed set.
- Boundary = SKIP, not clamp. In
scanOneThreshold:if !now.Before(*sub.CurrentBillingPeriodEnd) { return false, nil }. The same-tick cycle scan (scheduler step 0.5 precedes step 1 in one goroutine;scheduler.go:361→402) bills the whole period through the full-fidelity path. Clamp-and-fire is money-correct (watermark protocol atengine.go:2295/2402closes both orders — verified, no gap, no double-bill) but bills the full period through fireThreshold’s crude path: unprorated in_arrears base, no item-change segments (engine.go:2158/2299), no terminal-cancel in_advance skip (engine.go:2121), no in_advance header shift — mis-billing exactly the subs with a mid-period swap or scheduled cancel. Accepted semantic loss: nosubscription.threshold_crossedevent for a crossing first observed on/after the boundary tick — assert in test, note in ADR/CHANGELOG. - Fire-once probe placed in
scanOneThresholdBEFOREevaluateThresholds:LatestThresholdPeriodEnd(ctx, sub.TenantID, sub.ID, periodStart, periodEnd); non-ErrNotFound hit →return false, nil. This also skips the full previewWithWindow aggregation for fired subs — what makes the drained scan affordable. Preserves today’s reset=true-advance-failed repair path (cycle-close watermark,engine.go:2123-2142, never a fire retry). The probe is check-then-act, NOT the exactly-once mechanism — the unique index is the correctness seam (document in the probe’s comment; never write “CAS already guarantees exactly-once” in the PR). - Cursor drain: add
afterIDtoListWithThresholds/ForClock(WHERE s.id > $cursor ORDER BY s.id LIMIT batch); loop inScanThresholdsuntil short batch; cursor advances past a failing sub (never re-process within one tick). No max-pages guard (strictly-increasing cursor can’t loop; no belt-and-suspenders). Verify EXPLAIN on the cursored query once in the integration test (the 0056 index may not serve it). Accept per-tick evaluation cost at pre-launch scale; recordlast_threshold_scan_atrotation as the named future trigger in the PR description. - reset=true base proration — in
evaluateThresholdspost-processing ONLY;preview.gountouched (public create_preview surface; the reset=false cycle-close base-skip atengine.go:2285-2297depends on it). [AMENDED 2026-07-02 by the P1b build-time panel — three lenses, all SHIP-WITH-FIXES] The originally-sketched denominatorroundDays(periodEnd−periodStart)was WRONG: it violates the codebase’s own proration invariant (denominator = FULL cycle length,domain/subscription.go:248-256), over-bills multi-fire cycles ~13%, mid-cycle-created stubs up to ~2x, cross-interval items ~6x, and divides by zero on a sub-day re-anchored window. Corrected formula mirrorsemitBaseSegmentLineper line:fullCycleDays = roundDays(advance(periodStart, linePlan.BillingInterval) − periodStart),segDays = roundDays(now−periodStart), fraction capped at 1,money.RoundHalfToEven. The post-processing must rewrite the whole line triple (Description gains the standard(prorated X/Y days)suffix, UnitAmountCents recomputed so qty×unit==amount), not just AmountCents. The prorated amount feeds the running subtotal. reset=false full-base behavior stays byte-identical (mutation test). Accept $0-base-for-sub-day-stub. Fire→reset must be ATOMIC:CreateInvoiceWithLineItems+UpdateBillingCyclein one coordinator tx (crash between them strands the reset forever — the fire-once probe blocks every retry — which under proration becomes permanent base under-billing); Stripe calls (CommitTax/charge) stay post-commit. - max/last split-billing — exclusion applies iff
reset=false. Unconditional exclusion creates a usage-billed-by-NOBODY gap under reset=true (the fire re-anchors the cycle; the pre-fire stub never gets a cycle close). [AMENDED by the P1b panel] (i) Granularity is the rule bucket, not the meter — one meter can carry sum AND max rules (meter_pricing_rules.aggregation_mode); plumb AggregationMode onto PreviewLine and classify per bucket, set ={max, last_during_period, last_ever}(last_ever was missing from the original sketch). (ii) The cycle-close clamp exemption keys on invoice ground truth (the watermark threshold invoice carries no line for the bucket), never onsub.BillingThresholds.ResetBillingCycleat close time — the flag is operator-mutable between fire and close, and a stranded reset makes it a lie. (iii) Subtotal split: the invoice header + tax input = sum of post-exclusion rendered lines ONLY;RunningSubtotalsurvives solely for the cap comparison + event payload (otherwise dropped lines charge the customer invisible money). (iv) Empty-billable-set guard sits inscanOneThresholdafter evaluate, strictly BEFOREfireThreshold(no invoice-number mint, no paid tax calc), with a once-per-(sub,period) loudness floor: audit/timeline artifact + deduped WARN, not tick-spam. PATCH-time steering (warn on pure-max/last + amount_gte configs) deferred — cross-domain; recorded in the ADR. Panel decisions on the two open questions: (a) max/last amounts count toward amount_gte iff reset=false (asymmetric — counting them under reset=true creates a runaway refire loop: steady peak re-materializes in every re-anchored window → one invoice + card charge per tick; asymmetry kills it structurally); (b) pure-max/last crossing = skip with the loudness floor (residual-subtraction rejected by all three lenses: meaningless forlast, over-billsmax, unreconcilable invoice). - $0 MarkPaid — fix BOTH writers. Gate becomes
creditApplyOK && inv.Status == InvoiceFinalized(droptotalWithTax > 0) infireThresholdANDbillOnePeriod(engine.go:2648guard requires subtotal==0 AND len==0, but zero-priced nonzero-qty usage lines are emitted perengine.go:2514/2585— cycle close can also strand a $0 payment_pending-forever invoice, and no other PR can take it without a file collision). Tax-pending $0 drafts untouched (status gate excludes them). [AMENDED by the P1b panel] Add a heal-on-re-entry: a crash between invoice-create and MarkPaid otherwise re-strands the $0 invoice forever (both re-entry paths — the probe and ErrAlreadyExists — skip the gate); the probe/ErrAlreadyExists paths re-run the same idempotent gate. Panel verified Stripe parity (zero-amount invoices auto-marked paid,invoice.paidfires, no receipt) — fix 6 survived all three lenses otherwise unchanged.
- Ride-along: collapse the
:141/:150trialing double-gate to oneif sub.Status != domain.SubscriptionActive { return false, nil }with the trial rationale comment. [Shipped in P1a #338.] - [NEW — panel site-set discoveries] (7) Immediate-cancel final bill ignores the threshold watermark (
billFinalOnImmediateCancel): after a reset=false fire, an immediate cancel double-bills the pre-fire sum usage + base — the cancel path is a second period-closer absent from the original site-set. Fix: share the watermark protocol (base skip + additive clamp + non-additive exemption) withbillOnePeriodvia one helper. (8){in_advance base × reset=true}under-bills one base slice per fire on anniversary subs (customer-favored) — documented/deferred in the ADR, not fixed in-window. - [SPLIT] P1b = fixes 4 (corrected) + fire→reset atomicity + 6 + heal. P1c = fix 5 per-bucket machinery + immediate-cancel watermark (7). Same lane, sequential merges.
Test matrix (mutation-verified, real Postgres where stated): T1 fire-once — tick 2 after a reset=false fire: zero NextInvoiceNumber + zero ApplyTax calls (mock counters), zero new tax rows (Postgres). T2 downtime collision — scheduler dead across periodEnd: usage in [periodEnd, firstTick) billed exactly once by next cycle close; boundary tick returns fired=false while same-tick RunCycle bills the old period once. T3 residual — fire day 10, natural close bills only [fire, periodEnd) + zero base (extend threshold_cycle_residual_test.go with the probe in place). T4 reset=true proration — $50/mo, fires day 9 + day 24 → prorated bases sum ≈ time-weighted; reset=false full base unchanged (negative case). T5 max reset=false — peaks 100/90 → 100 billed total, no max line on threshold invoice, cycle close un-clamped. T6 max reset=true — pre-fire peak billed on threshold invoice, nothing unbilled (mutating the reset=true arm to exclude must FAIL). T7 last-aggregation variants of T5/T6. T8 pure-max reset=false crossing → no invoice, errsOut empty across 3 ticks. T9 drain — 60 subs, batch 50, sub #51+ fires same tick (Postgres). T10 drain+probe — 60 fired subs drain with zero number burn. T11 concurrent double-fire → unique index holds at-most-one invoice (exactly-once side-effect assertions apply only to the sequential re-tick case). T12 $0 usage_gte crossing → finalized+paid, not dunned, both writers; restoring totalWithTax>0 must fail it.
4.2 P2a/P2b — Money-against-intent
Section titled “4.2 P2a/P2b — Money-against-intent”Archive (P2a) — decided: 409-block, never auto-cancel. Auto-cancel is a trap: Cancel() (service.go:1439) runs BillFinalOnImmediateCancelTx + post-commit finalize, which auto-charges the saved card for the partial period — “archive to stop billing” would itself charge the card N times in a bulk loop and drags in the full ADR-056/057 proration machinery. Design: customer.Update rejects active→archived while any sub is non-terminal (active | trialing | paused | scheduled-cancel-pending — scheduled-cancel still bills until the boundary), via a narrow SubscriptionChecker interface wired into customer.Service (precedent: SetStripeSyncer; zero peer-imports rule). subscription.Create piggybacks the existing :494 customer fetch: status=archived → 409. Archived-plan guard (:422 + swap-to-archived in UpdateItem) ships here — same enforcement seam. CustomerDetail.tsx:439 copy states exactly: archive hides from active lists + blocks new subscriptions; existing unpaid invoices remain collectible and dunning continues (dunning is invoice-driven and survives archive); UI offers per-sub cancel affordances on 409. No backfill for already-archived customers (pre-launch, zero customers); no belt-and-suspenders engine skip.
Hosted-invoice polling (P2a) — pure FE, no panel. refetchInterval: 3000 while paidSignal && status==='finalized' && payment_status non-terminal; the public payload ALREADY carries payment_status (hostedinvoice/handler.go:500) — authoritative terminal signal, no new plumbing (no-heuristic-proxies rule satisfied). Stop on paid (success banner) or payment_status==='failed' (failure banner + re-enable Pay). Suppress Pay while polling. Cap at ~3 min, then honest copy (“confirmation is taking longer than usual — you will not be charged twice; a receipt email will follow”) without re-enabling Pay (async methods legitimately take longer; with session reuse a re-click returns the same session anyway). No thundering herd (single payer; 60/min/IP at router.go:1021 vs ~20/min at 3s).
[AMENDED 2026-07-02 by the P2b build-time panels — 6 lenses (3 per design), all SHIP-WITH-FIXES. The protocol below SUPERSEDES the sketches; implement against THIS.]
Checkout dedup — consolidated protocol:
- Claim-first: INSERT the open claim row (session_id NULL) in a tx that re-verifies the invoice payable via
SELECT … FOR SHARE(serializes vs the paid-flip’s FOR UPDATE — kills the mint-vs-settle TOCTOU where a session minted after expire-on-settle already ran survives on a paid invoice). Stripe create AFTER commit with an idempotency key derived from the claim row id (cks_<row_id>); fill session_id/url via CAS. Crash between insert and create = next POST re-drives the create with the SAME key → same session (never a second). Stale NULL-session placeholder (> ~2min) taken over via CAS. - Concurrent-loser protocol: unique-violation loser re-reads the winner row → URL present: 200 {url}; still pending: re-drive create with the winner’s claim-derived key (converges on one session). Never 5xx, never 200-with-empty-url (test the invariant).
- Expire-on-settle = store choke points IN-TX, not 10 hand-stitched call-site hooks: close checkout_sessions rows inside
markPaidReportingTransition(every MarkPaid variant converges there), void/uncollectible status transitions, andApplyCreditNoteTxwhen amount_due hits 0 (creditnote Issue’s unpaid branch + issue_pending reconciler exit payable with NO settle event — the four-category list undercounted; the real census is 9+ sites). Stripe network expire stays post-commit best-effort from ONE shared helper; ExpiresAt is the backstop. - ExpiresAt=1h ON THE STRIPE CREATE PARAMS (DB-only TTL leaves a 23h invisible payable session — Stripe default 24h); persist Stripe’s ACTUAL expires_at from the response; reuse read requires
status='open' AND expires_at > now()(+margin); time-expired rows are CAS-superseded (WHERE status='open' AND expires_at<=now()) then reminted — else customers get a permanently dead Pay URL. - Amount-mismatch path branches on WHY expire failed: session complete → NO mint, 409 “payment already in progress” (webhook will land); already-expired → success, mint; network error → mint (accepted ≤1h residual).
- Settle-time amount check: thread the PI’s captured amount into SettleSucceeded (webhook already parses it and throws it away); compare vs amount_due at the FOR-UPDATE transition; mismatch → escalate (audit row + per-cause
payment.amount_mismatch+ durable marker). A CN can legally reduce amount_due while a session is open (open ≠ in-flight, ADR-059 doesn’t cover it) — the drift residual must be DETECTED, never silently booked (amount_paid=60 while Stripe captured 100 corrupts the refund cap). - Duplicate-PI escalation mechanics: at the :47 fast path compare against the fresh row; at :77 compare against the invoice RETURNED by MarkPaidCardSettlementTransition (currently discarded — the stale caller snapshot false-alarms on routine concurrent same-PI settles, since checkout invoices have no recorded PI until settle). Predicate: escalate unless recorded == incoming. A payment landing on a VOIDED invoice gets a DISTINCT cause (
payment.received_on_voided_invoice), not duplicate_charge. - Operator surface is load-bearing (auto-refund deferred ⇒ operator IS the refund mechanism): persist a durable marker + AttentionReason
duplicate_charge(Critical, both PI ids, Stripe deep-link), excepted from the attention classifier’s terminal-state early-return. - In-flight guard on POST /checkout: payment_status in-flight → 409 (parity with void/uncollectible/offline guards; dunning-auto-retry-vs-Pay-click is the most likely collision).
checkout.session.completedmarks the row complete (truth-sync; full session reconciler stays deferred). Rollout note: pre-deploy sessions aren’t deduplicated, expire ≤24h.
Trial cancel — consolidated protocol:
- cancel_at for trialing subs: exactly two legal values —
== trial_end_at(free cancel via the guard) or>= current_billing_period_end(activate, bill period 1, cancel at its close). REJECT the open interval (trial_end, period_end) with a clear 400 — the sketch’s>= trial_end_atlegalized a value honored at NEITHER timestamp (full period billed, cancel fires a period late). - The guard must be atomic in SQL, not Go-only (TOCTOU at all four sites): the activation UPDATEs (ActivateAfterTrial + EndTrialEarly) gain
AND cancel_at IS NULL AND NOT cancel_at_period_endvia honestly-named store methods (e.g. ActivateAfterTrialUnlessCancelScheduled — visible control flow, satisfies the no-hidden-control-flow rule). Zero rows → re-read and route (schedule appeared → cancel path; already canceled → no-op; never bill on the conflict path). - Dedicated trialing→canceled transition is a CAS on the fields that justified it:
WHERE status='trialing' AND trial_end_at=$observed AND (cancel_at_period_end OR cancel_at <= trial_end_at)— else it cancels customers who rescinded (ClearScheduledCancel race) or extended (ExtendTrial race). Zero rows → typed no-op. - Engine trial branch: never bill on activation conflict — on guard-fire OR InvalidState from activation, re-read; if status != active → return WITHOUT building the invoice and WITHOUT advancing the cycle (today’s fall-through would bill a canceled sub with the stale trialing snapshot).
- ScheduleCancellation CAS on the status the intent was validated under (
AND status=$observed) → 409 on conflict (the flag is status-polymorphic; landing on a just-activated sub silently means the opposite). - EndTrial’s 409 also in SQL (the WHERE gains the schedule-empty predicate; the no-row diagnostic maps to the same 409).
- ExtendTrial: 409 when a pending EXPLICIT cancel_at exists (flag-only schedules pass — they move with the trial by design; an explicit timestamp would be silently overridden/stranded).
- Winner-fires-once: terminal effects (subscription.canceled with canceled_by=schedule + reason=trial_end_cancel, AuditActionCancel row) fire ONLY from the UPDATE winner, via ONE shared helper across all four sites. Explicitly NO subscription.trial_ended (consumers read it as “billing begins”), NO invoice.
- canceled_at == trial_end_at at every site (the engine fires up to a full interval late; stamping site-local now misbooks the lag as billable service). Define the nil-TrialEndAt fallback.
- cancel_effective_at derived read-only field on subscription JSON + cancel_scheduled payload (trialing+flag → trial_end_at; active+flag → period_end; else cancel_at); banner, Cancels-on tile, dialog read it (both existing surfaces show the WRONG date under the new semantics — Hardcount’s period_end ≠ Stripe’s trial-end-while-trialing).
- Align the two non-atomic activation sites (clock scan + engine branch bill AFTER the flip with a lying “deferred” WARN — it’s a permanent revenue leak per ADR-056) onto the existing WithBill coordinator-tx shape in the same PR.
- Immediate Cancel() of a trialing sub: during-trial is verified free today (no rider bug); the post-trial-lag case (trial elapsed, scan lagging, still status=trialing) DOES bill [trial_end, canceledAt] — decided: WRITE OFF (trials are free; matches the scheduled-cancel semantics); suppress final-bill for status=trialing and regression-test both timings.
- UI: dialog copy “At trial end — you won’t be charged”; disable End-trial-now while a schedule is pending (inline reason); surface the post-activation schedule landing (toast with real cancel_effective_at); trial-canceled subs don’t render the phantom never-billed period card.
- Same-PR docs: rewrite the three now-lying comments (service.go:1570-1574, postgres.go:357, handler), OpenAPI text for schedule-cancel + end-trial, CHANGELOG semantics note, MANUAL_TEST flow.
P2b ships as two sequential PRs on the billing lane: P2b-a (checkout dedup, migration 0125) then P2b-b (trial cancel).
Checkout dedup (P2b) — panel #1 before code. Reuse key = (invoice_id, amount_due_cents, livemode): on POST /checkout return the stored open session only if its amount matches current AmountDueCents; on mismatch, best-effort expire the old and mint fresh — do NOT chase every amount_due writer (unbounded site-set); ExpiresAt=1h bounds the residual, and ≤1h residual is documented and accepted (today it’s unbounded). Persistence: checkout_sessions table (invoice_id, session_id, url, amount_cents, livemode, status, expires_at) with a partial unique index (one open row per invoice) as the concurrent-double-POST guard. Expire-on-settle site-set = every exit from payable state, not just card settle: card settle (all four SettleSucceeded sources), operator offline MarkPaid, void, credit-application-to-zero; hang the expire off each as a post-commit best-effort side effect (Stripe calls never ride a tx — matches settlement.go:108 durability tiering); in SettleSucceeded gate on transitioned==true (existing FOR-UPDATE once-only gate); ExpiresAt is the backstop when the expire call fails (two mechanisms, independent failure modes — justified). Uncollectible path: payEnabled already false, but call the same helper. Duplicate-success escalation: the :47-54 skip is CORRECT for same-PI redelivery; escalate only when the incoming PI differs from the invoice’s recorded PI — slog.Error + per-cause payment.duplicate_charge webhook event + audit row — at BOTH skip points (:47 fast path and :77 transitioned=false). No auto-refund (deferral recorded in the ADR). Panel prompts: crash between session-row insert and Stripe create; concurrent double-POST; expire-call failure; amount drift; livemode mismatch.
Trial cancel (P2b) — panel #2 before code. The site-set has FOUR activation writers, and the plan originally missed the fourth: ProcessExpiredTrials (service.go:1850), ProcessExpiredTrialsForClock (:1764), EndTrial (:1697), and billing/engine.go:1941-1953 (billSubscription trial branch: ActivateAfterTrial then falls through to normal billing — fires whenever next_billing_at beats the trial scan, resurrecting the exact audit failure). The engine guard (“trialing && trial over && (cancel_at_period_end || cancel_at ≤ trial_end) → cancel, do not activate/bill”) lands in P2b AFTER P1 merges — explicit merge order, not disjointness hope. Do NOT hide the guard inside store.ActivateAfterTrial (returning a canceled sub into engine code that keeps billing = hidden control flow). Trial-end cancel is a dedicated store transition (trialing→canceled, canceled_at=trial_end, clear schedule fields) that emits NO invoice — trials are free; do NOT reuse Cancel() (BillFinalOnImmediateCancelTx only checks periodStart < canceledAt < periodEnd and would emit an in_arrears prorated base invoice for a free trial; add the regression test that immediate Cancel() of a trialing sub emits no invoice — if it bills today, that’s a rider bug fixed in this PR). Semantics: for status=trialing, AtPeriodEnd means cancel-at-trial-end (Stripe semantics; keep the flag, do NOT eagerly convert to a stored cancel_at — ExtendTrial :2033 would strand a pinned timestamp); allow explicit cancel_at >= trial_end_at (fixes the :1571 guard that makes trial-end cancel inexpressible); EndTrial with a pending schedule → 409 “clear the scheduled cancel first” (no silent precedence). Update ScheduleCancelInput doc comment + SubscriptionDetail.tsx dialog copy (“At trial end — you won’t be charged”). Panel prompts: each of the 4 sites racing the schedule write; EndTrial-with-schedule; ExtendTrial-after-schedule; clock-pinned catchup ordering; in_advance day-1 invoice suppression.
4.3 P3 — Tenant-scoped billing run
Section titled “4.3 P3 — Tenant-scoped billing run”Design locked in §1/P3. The three correctness-deciding details: sanitized per-sub errors (ID + class in the body, detail to slog); 403 fail-closed on empty tenant scope (platform keys — an unscoped fall-through recreates the global sweep); drain-until-empty with bounded passes (single 50-batch pass silently under-bills). Site-set verified complete: GetDueBilling/RunCycle callers are scheduler.go:402 and handler.go:33 only. Money-path → short panel before build (the site-set above seeds it).
4.4 P14 + P4 — panel outcomes (AMENDED 2026-07-02; 6 lenses — 3 per design — all SHIP-WITH-FIXES. Implement against THIS.)
Section titled “4.4 P14 + P4 — panel outcomes (AMENDED 2026-07-02; 6 lenses — 3 per design — all SHIP-WITH-FIXES. Implement against THIS.)”P14 credit expiry — consolidated protocol:
- Primitive LOCKED:
ExpireGrantAtomic(store method), ONE tenant tx per grant. Theconsumed_cents = amount_centsflip is the correct retirement primitive (consumed_cents is a mutable read-model column, not event history — exactly Orb’s block model this ledger already implements), but ONLY inside a single tx that: (a) takes the customer advisory lock via theappendEntryInTxmachinery (balance_after correctness), (b) locks the customer’s ledger rows with the SAMESELECT … FOR UPDATEstatementApplyToInvoiceAtomicuses (postgres.go:350) — apply↔expiry mutually exclude ONLY through this row lock; the advisory lock does NOT cover apply/adjust, (c) re-reads amount/consumed UNDER the lock (the sweep’s Go list snapshot is stale by construction), (d)remaining <= 0→ clean no-op (no entry, no error), (e) CASUPDATE … SET consumed_cents = amount_cents WHERE id=$grant AND consumed_cents < amount_cents= the exactly-once gate, (f) INSERT the-remainingexpiry entry stampedcreated_at = expires_at. Reject any two-tx split: entry-then-flip strands phantom headroom permanently excluded by the description-LIKE dedup; flip-then-entry strands undrainable balance that can never expire. - Decision LOCKED: retirement wins over backdated apply. A committed retirement is never re-admitted for ANY
at; a backdated apply must still drain NOT-yet-retired grants (do NOT clampat, do NOT change theexpires_at > $ateligibility filter — the consumed_cents gate is the sole new exclusion). Entitlement-loss bound (cross-Advance partial-failure replays) documented in the ADR. processExpiry= candidate discovery ONLY; all per-grant work delegated toExpireGrantAtomic.- Same-PR migration
0127(claimed 2026-07-02 across all branches; P9/P11 shift to 0128+): retro-retire legacy expired grants —SET consumed_cents = amount_centswhere a matching expiry entry exists (exact-match description join'Expired grant ' || g.id, used one last time; also repairs headroom already re-opened by the bug). THEN delete the description-LIKENOT EXISTSfromListExpiredGrantsANDListExpiredGrantsForClock—consumed_cents < amount_centsbecomes the single structural exclusion (the LIKE dedup is operator-visible display copy doubling as an idempotency key; a copy edit would re-expire the whole book). - Ride-alongs (same file, panel-confirmed): (a)
AdjustAtomicdiscardsdrainPositiveBlocks’ return (postgres.go:213) — a negative adjustment whose only headroom is an expired-unswept grant inserts unconditionally and drives SUM negative sequentially, no race needed; it must use the drain result and fail loudly whendrained != -amount. (b)Adjustgains Grant’s100_000_000cap on positive amounts (service.go:427— unbounded drainable blocks today). - Docs: consumed_cents domain comment redefined as retired capacity (usage-drained + expired); MANUAL_TEST TC10 gains one observable (post-expiry apply consumes $0 from the retired grant); ADR records: retirement-wins decision, the chronological-running-balance transient-negative display artifact (accepted, display-only — expiry stamped at expires_at + backdated usage can dip a UI row negative), and the void-reversal-regrant-loses-expiry Stripe divergence as a NAMED BACKLOG item (do not expand P14).
- Tests (real-PG, mutation-verified): barrier-synchronized concurrent expire vs backdated-apply on the same grant, both orderings, second active grant present → final SUM ≥ 0, attribution exact; backdated apply AFTER retirement → drains 0 + drift WARN fires; double sweep (sequential AND concurrent) → exactly one expiry entry per grant, no zero-amount entries; partial-consumption expiry (60/100 → entry −40, TotalExpired=40); legacy-shape backfill migration test (expiry entry present + consumed=0, plus partially-consumed variant); AdjustAtomic clawback vs expired-unswept headroom → loud error. Mutations: remove the flip → backdated-apply test fails; compute remaining from the list snapshot instead of under-lock re-read → race test fails; inject failure on the expiry INSERT → flip rolls back.
P4 price-change — decisions LOCKED (ADR-070), implementation Day 3–4:
-
Decision (a): key overrides by
rule_key. Addrule_keycolumn backfilled via join to rating_rule_versions;UNIQUE (tenant_id, customer_id, rule_key)replaces the version-id unique; backfill collapses collisions deterministically (highest version wins, tie-break latest updated_at; losersactive=false, kept for audit, each demotion logged); API keeps acceptingrating_rule_version_id, resolved to rule_key at the service boundary. Reject carried-forward/copy-on-publish. -
Decision (b): PINNED, never retroactive — resolve-as-of-period-start. ONE shared resolver (version in force when the billing period OPENED prices the WHOLE period) used by ALL raters: single-rule close (
engine.go:2839), multi-dim close (:2771), both cancel paths (:4044/:4121), preview (preview.go:229-231), threshold (via previewMeter), customer_usage (P10 slice). A publish takes effect at the next period open. The SAME effective-next-period semantics applies to ALL rate-state writers: version publish, override upsert, override deactivate — mixed semantics collapses the spend-cap coherence argument (threshold compares capRunning at old rates, close bills at new). -
Resolver mechanics: as-of resolution requires an explicit
effective_fromstamped viaclock.Now(ctx)(createRatingRuleTx stamps wall-clock today — sim-time periods would resolve against wall-clock publish stamps under test clocks) +lifecycle_stategating (an archived/draft version must not be “latest”). -
ToRatingRulePATCHES the resolved rule, never replaces it: keep resolvedID,RuleKey,Currency,Name; swap only pricing fields. Today’s fabricated rule zeroes Currency → preview totals silently EXCLUDE every overridden line (computePreviewTotalsskips empty-currency,preview.go:303-305) and threshold fires hard-bail “no invoice currency resolved” for usage-only all-override subs. -
No silent fallbacks: all 5
GetOverridesites distinguisherrs.ErrNotFound(proceed at list price) from ANY other error (abort the close/preview, return the error — today a DB blip silently bills list price);GetLatestRuleByKeyresolution failure is a returned error at both close paths, never a pinned-version fallback. -
Currency guard: publish rejects a currency change within a rule_key while active overrides reference it (rule_key-following overrides would silently reinterpret integer cents in the new currency).
-
Tier validation full-shape at authoring (strictly-increasing
up_to+ required final catch-all) invalidateRatingRuleInputANDCreateOverride— the qty=1 probe never reaches tier 2, so malformed tables detonate at cycle close; pre-existing bad rows still fail loud at close (scan + WARN). -
CreateOverride: tenant-scoped rule resolution + 400 on miss (mirrors
service.go:473-475; global FK is a cross-tenant existence oracle today). -
DELETE /overrides (soft deactivate), effective next period per decision (b); test asserts deactivated override bills list price (positive AND negative).
-
Version allocation in SQL (
INSERT … SELECT COALESCE(MAX(version),0)+1+ retry-on-23505): kills the concurrent-publish spurious 409; recipe reinstall allocates the next version via the same path instead of hardcodedVersion:1(recipe/service.go:452). -
Tests: detach regression (publish v2 mid-period, close bills override, single-rule AND multi-dim; mutation = restore version-id lookup); preview == invoice parity across a publish; fire-then-publish-then-close → residual bills the SAME version as the fire; threshold fire on zero-base all-override sub succeeds with real currency; preview totals include overridden lines; transient GetOverride error → close FAILS (fake store); malformed tiers rejected at both POST endpoints; real-PG migration collision test (v1+v2 override rows → one winner); recipe uninstall→reinstall round-trip; customer_usage == invoice for an overridden customer (P10).
-
ADR-070 additionally records: an override freezes PRICE, not meter/rule semantics (name/currency of new versions still flow — vs Lago’s full-immunity model); deactivation mid-period semantics; pin-at-consumption REJECTED (period-start grain is the v1 semantics; nothing spills to backlog from decision b).
Correction (2026-10-23): Lago has no full-immunity model. Lago edits plans in place. A premium per-subscription override keeps one customer apart only in part: a plan edit sent with
cascade_updatesalso reaches the override’s child plan where it kept the old prices. See the correction in ADR-070.
[P4 SHIPPED 2026-07-03 — implementation deltas from the protocol, all recorded in the amended ADR-070: resolver keys on created_at stamped via clock.Now (no effective_from column — versions are immutable, creation IS effectivity; forward-advancing clocks coherent, backdated clocks accepted caveat); overrides are append-only effectivity rows ([created_at, deactivated_at) windows, migration 0128) so override EDITS are also period-open-pinned; recipe reinstall ADOPTS the whole graph by natural key (rules/meters/plans — version-bumping would reprice live subs; webhook-endpoint dup = named residual); key-born-mid-period resolves earliest active version, override absent at open = list price.]
4.5 P5 — transport panel outcome (AMENDED 2026-07-03; 2 lenses, both SHIP-WITH-FIXES. Implemented against THIS.)
Section titled “4.5 P5 — transport panel outcome (AMENDED 2026-07-03; 2 lenses, both SHIP-WITH-FIXES. Implemented against THIS.)”- Lease arithmetic corrected: the locked “BatchSize×30s + 60s” undersized the true email per-row worst case ~2× (suppression 5s [newly bounded] + branding 5s + dial 10s [disjoint from the exchange deadline] + SMTP 30s + bounce report 5s ≈ 55s → PerRowBudget=60s, ENFORCED by a per-row ctx). Invariant chain, CI-locked: BatchSize×PerRowBudget ≤ BatchTimeout < ClaimLease(BatchSize)=BatchSize×PerRowBudget+60s. Shipped constants: BatchSize 10→5, BatchTimeout 60s→300s, ClaimLease 360s.
- Marks detached: every per-row mark (dispatched/retry/DLQ) runs on WithoutCancel+5s CAS (WHERE status=‘pending’) — a mark for work performed always commits; a stale mark never regresses a terminal row. Row starts gated on a small remaining-budget floor (not the full budget — marks being detached means outcomes are never lost; the floor only shrinks the duplicate window).
- Attempts increment moved INTO the claim UPDATE (single site; claim-then-crash advances toward DLQ; poison rows per-row-recovered and classified permanent). Permanent errors (payload decode, ErrRecipientSuppressed, permanent 5xx) DLQ immediately.
- Webhook constants SPLIT (one constant provably can’t serve both): birthLeaseWindow=120s on rows created in the SAME tx as the event (in-process goroutine owns attempt 1; retry worker = crash backstop); retryClaimLease = claimBatch×13s+60s with claimBatch 100→10 (190s). Retry worker leader-gated (LockKeyWebhookRetry). NULL next_retry_at claim branch RETAINED (legacy-rollout coverage).
- handler-owns-mark: DispatchFromOutbox marks the producing webhook_outbox row inside the event+deliveries tx — the crash window that minted receiver-undedupable duplicate events is structurally gone; ProcessBatch’s own mark = CAS no-op backstop. UpdateDelivery = CAS, errors surfaced (ErrStaleDeliveryMark), never
_, _ =. - SMTP: ONE deliver() (the smtp.SendMail path had NO timeouts and OPPORTUNISTIC StartTLS = downgrade-strippable); strict STARTTLS; SMTP_TLS=none forbidden in production (fail-loud at send — the code comment used to claim this check existed); AUTH only when advertised+configured; env var is SMTP_TLS everywhere (the plan text’s SMTP_TLS_MODE was wrong); boot-time transport-mode log; suppression check bounded 5s.
- Redelivery semantics per row state (ADR-072): pending = at-least-once (one duplicate possible per crash/lease-expiry); dispatched = terminal-sent; failed = terminal-unsent until operator requeue (documented UPDATE statement in the ADR — post-repair recovery exists).
4. Day-by-Day, Critical Path, Cut-Line
Section titled “4. Day-by-Day, Critical Path, Cut-Line”Calendar reality: Jul 3 = Friday; Jul 4–5 = Saturday/Sunday — this plan assumes both weekend windows are worked (confirmed at lock; if either is lost, the plan collapses to Tier 1 only and P4/P7-core move up a day). Each day = one quota window. Capacity math: ~2 substantive money PRs + 1–2 S riders per window (proven by #306–#312, #325–#331); 14 batches ≈ 110% of that — hence the cut-line and pre-split trim lines, locked now so a Day-3 slip triggers a pre-agreed drop, not a renegotiation.
Critical path: P3 → P1a → P1b → P2b → P4-impl (subscription/postgres.go, then engine.go ×3, plus subscription/service.go via P2a→P2b). P4 cannot start before Day 3; any P1 slip pushes the last money PR onto the deadline — which is why P1a (the double-billing kernel) must merge Day 1 regardless.
| Day | Lane A (billing, serial) | Parallel lanes | Riders / admin |
|---|---|---|---|
| 1 — Fri Jul 3 | Merge P3 at window-open. P1 lean panel (incl. fix-5 semantics) + P4 keying + retroactive-repricing decision + ADR. Implement + merge P1a; P1b if tokens remain. | P14 panel + implement (front-loaded money bug). P2a implement + merge (no panel). | Claim migration numbers 0125+ (P9×3, P2b×1, P11×1) across all branches. End-of-window: run P2b’s two panels on residual tokens. |
| 2 — Sat Jul 4 | Merge P1b. Start P2b impl (panels done Day 1). | Merge P14 (if not Day 1). P6. P9-core (trigger + 2 indexes). | P8 as end-of-day rider if tokens remain. |
| 3 — Sun Jul 5 | Merge P2b. Rebase + implement P4 (decision locked Day 1). | P5-core. P8 (if not landed). | Pipeline P7 walkthrough prep. |
| 4 — Mon Jul 6 | Merge P4. | P7-core + literal walkthrough. P12 → P11-core (router lane). P10-core. | Land P7 migrate.go before P9’s CONCURRENTLY migration if both in flight. |
| 5 — Tue Jul 7 | BUFFER FIRST — absorb any slip. | Then, in order: P10 remainder, P11 sweep + flag deletion, P13, P9 query rewrites, P5 localhost rider (only if P12 + P7 merged). | Anything untouched at window-close goes to backlog without discussion. |
Cut-line (drop from the bottom, never reorder):
- TIER 1 — must-ship before any DP: P3, P1a, P2a, P2b, P14, P6, P8. (All DP-blocking money/tenant-isolation, or S-cheap.)
- TIER 2 — should-ship: P1b, P4 (override-detach + delete-override + tier validation + recipe :428; repricing per Day-1 decision), P5-core, P7-core, P9-core, P12.
- TIER 3 — stretch: P10-core, P11-core, P13, P9 query rewrites, P10 N+1 rework, P11 full sweep + feature-flag deletion.
- TIER 4 — post-Jul-7 backlog (named deferrals): P4 pin-at-consumption implementation (iff that decision wins), P5 localhost knob + webhook event-idempotency schema, P7 doc riders, and the ~41 remaining lows (50 minus the 9 ride-alongs:
threshold_scan.go:508→P1,creditnote/handler.go:248→P6,CustomerCombobox.tsx:1→P11,bootstrap.go:58→P7,subscription/postgres.go:896→P3, credit:426→P14,router.go:1082→P12,HostedInvoice.tsx:342→P2a,adapters.go:610→P2b). The audit report is the backlog tracking doc.
5. Rules of Engagement
Section titled “5. Rules of Engagement”- Panels are lean and pipelined. The audit already did the adversarial discovery; a panel’s job now is fix-shape review — one adversarial pass against the enumerated site-set, no multi-agent fan-out. Panels required: P1 (fix 5 only), P2b (×2), P14, P4, P3 (short), P5 (short — lease arithmetic + crash points). P8 gets site-set enumeration only. End each window by running the next day’s panel on residual tokens so implementation starts at window-open.
- Site-set enumeration in every PR body (playbook rule: never reason locally). The enumerations in §4 are starting points, not substitutes.
- Self-merge gate: green CI + mutation-verified regression test + docs riders (CHANGELOG, MANUAL_TEST flow, ADR where a decision was made) in the same PR. Verify the real path, not a shortcut; positive AND negative assertions.
- Worktree discipline: one worktree per PR off origin/main; never open a lane-successor’s worktree until the predecessor is merged (a conflicting PR gets no CI — every out-of-order push burns a ~6-min cycle + rebase). Rebase onto origin/main before push, always.
- Quota pacing: money-PR-first each window; S riders fill residue; if a window opens with the previous day’s lane PR unmerged, finish it before starting anything new. CI is the only integration gate (shared local Postgres — no parallel
-short=falseruns). - Migration/ADR numbers claimed Day 1 across origin/main + all local branches; drop-table downs recreate (feature_flags down re-seeds 0001/0002 rows).
- Tracking: the deduped report (16/51/50) is the ledger; each merge marks its findings AND its dupe-roots closed. Lows stay deferred except the 9 named ride-alongs — no opportunistic low-fixing outside owned files.
- Decisions locked in this doc (archive=409-block; feature-flags=delete; boundary=skip; STARTTLS=strict; max/last-iff-reset-false; duplicate-PI-only escalation) are not reopened mid-window; deviating requires amending this doc first (amend-decisions rule).
6. Risks
Section titled “6. Risks”- Quota exhaustion (proven: Jul 2’s window died mid-audit). A panel-heavy money day can consume a full window. Mitigation: lean panels, pipelining next-day panels into residual tokens, and the tiered cut-line so a lost window drops Tier 3 — never Tier 1.
- P1 blowup. Six fixes + engine-parity + real-Postgres mutation tests in the product’s weakest surface, heading the critical path. Mitigation: the P1a/P1b split — the double-billing kernel (skip + probe + drain) merges Day 1 regardless; P1b’s panel-gated fix 5 can slip a day without moving P2b (which touches a different engine function and rebases trivially).
- Serialization churn on the billing lane. Three engine.go successors + CI-as-only-integration-gate. Mitigation: strict merge order (§2 invariant), disjoint-function edits (rebases are mechanical), and parallel lanes (P5/P6/P9/P10/P13/P14) that provably never touch lane-A files.
- P4 decision stall. The only unmade product decision in a money batch (retroactive vs pinned repricing) sits behind three merges. Mitigation: the decision + ADR happen Day 1 in design-space only; P4-impl starts Day 3 against a settled ADR; pin-at-consumption implementation is pre-authorized to spill. P10’s
:401slice waits for the same ADR — cross-batch regression risk eliminated by construction. - Weekend capacity. Jul 4–5 carry P1b, P2b, P4, P5-core, P6, P9-core. If a weekend window is lost: execute Tier 1 only; P4 and P7-core move to Day 4; everything else spills per the cut-line — no renegotiation.
- P2b hidden-writer regression. The engine trial branch (
engine.go:1941) is the fix the original plan missed; skipping it re-ships the audit bug on a timer. Mitigation: test (11) in P2b’s matrix drives activation through the engine cycle path specifically, clock-pinned. - P7 verification is wall-clock, not CI. Docs/compose have no mutation-test surface. Mitigation: P7-core scoped to the three code items with real tests (bootstrap, APP_DATABASE_URL fail-closed, migrate-lock); the walkthrough is the doc riders’ gate, and those riders are pre-authorized spill; scheduled Day 4 so a spill lands in Day 5 buffer, not past the deadline.