RESEARCH

CODE REVIEW — prayer-engine (2026-09-15)

prayer/research/CODE-REVIEW-2026-09-15.md

CODE REVIEW — prayer-engine (2026-09-15)

Fresh-context, read-only review of /home/sha/vibing/prayer-engine against OPS-RUNBOOK.md §§1–10, SPEC-PRAYER-ENGINE.md §§1–3/§9, research/TAGADAPAY-DIGEST.md, DECISIONS-2026-09-14.md FINAL ANSWERS and COPY-CREATIVES.md.

Reviewer is Opus; implementation was authored by Opus workers. Same-model review is weaker than cross-model review. Points where an Opus author and an Opus reviewer would likely share a blind spot are marked [shared-blind-spot risk] for the Fable orchestrator to weigh.

Verdict summary

The pure-logic layer is genuinely good. The charge-intent guard, the order state machine, pricing/SKU names, the ph handle, the seven-check QA gate with the flame DELTA, the job queue with FOR UPDATE SKIP LOCKED, and the copy fidelity are all correct and match the specs closely. Typecheck is clean and 247 unit tests plus 17 Playwright specs pass.

What is missing is the layer where money and delivery actually happen. Production sends no email. The portal shows no photograph. No webhook endpoint exists. No cron is scheduled. Meta receives no purchase signal. A paused membership keeps billing. These are not polish items; each one alone stops the funnel from working end to end with a real buyer.

The README's "Status: foundation only" is stale — this is a near-complete funnel — but the repo is further from launchable than the passing test suite suggests, because the fakes are wired into the production path, not just the test path.


BLOCKER

B1. Production worker sends no email at all

apps/worker/src/context.ts:97

// Real transactional mail belongs to packages/email, which is off limits for this task.
mailer: new FakeMailSender((line) => console.log(line)),

Problem: createProductionContext() hardwires the fake mailer, so on a real droplet with a real DATABASE_URL the delivery email is a console.log. packages/email is fully written (17 templates, Resend adapter, render, send, webhook parser) and is not even listed in apps/worker/package.json dependencies. Fix: add @prayer-engine/email to the worker's dependencies and select ResendMailSender in createProductionContext whenever the brand's RESEND_API_KEY is set.

B2. The portal renders storage keys, not signed URLs

apps/web/src/app/portal/[token]/page.tsx:13 Problem: d.assetUrl goes straight into <img src>, <audio src> and <a href>. That column holds an object-storage key by design (packages/db/src/schema.ts:188, apps/worker/src/storage.ts:9-12). The web app has no storage client and no signedUrl; only the worker does. Every photograph in the portal is a broken link. Fix: add a storage client to apps/web, resolve a fresh signed URL per deliverable at render time, and never emit the raw key.

B3. A charge that times out is marked failed, which permits a second real charge

packages/payments/src/intents.ts:159-164, same pattern at apps/web/src/lib/charges.ts:101-104

try { outcome = await run(intent); }
catch (error) { await store.fail(intent.id); throw error; }

Problem: FetchTagadaHttp.request throws on any transport failure — DNS, reset, socket hang-up, and there is no request timeout at all (packages/payments/src/http.ts:87). A timeout is precisely the case where the rail may already have taken the money. Marking the intent failed frees the partial unique index WHERE status <> 'failed', so the customer's next attempt opens attempt_no + 1 and fires a second real charge. The digest is explicit: "No idempotency key on payments. Write a pre-charge intent row and never re-fire an in-flight charge." Fix: distinguish a definitive rail decline from an unknown transport outcome. Only a decline may mark failed. An unknown outcome stays in_flight, shows the buyer a "we are confirming this payment" page, and is resolved by the reconciliation sweep. [shared-blind-spot risk] — the happy path and the decline path are both covered by tests; the timeout path is the one an author and a same-model reviewer both tend to treat as a decline.

B4. The TagadaPay webhook endpoint does not exist

apps/web/src/middleware.ts:48 excludes api/tagada/webhook from the matcher; no route file exists anywhere under apps/web/src/app/api/. Problem: the middleware comment claims a handler that was never written. Every delivery 404s. Per the digest the rail makes 3 attempts in ~34 s and auto-disables an endpoint after 3 days of failures, so the endpoint dies quietly on day 3. No order/paid reconcile, no order/refunded sync, no subscription/rebill* or pastDue state, no dunning trigger. Fix: write apps/web/src/app/api/tagada/webhook/route.ts. Read the raw body, call TagadaPspAdapter.parseWebhook, return 2xx immediately, enqueue the work.

B5. The reconciliation sweep is a stub

apps/worker/src/cron.ts:83-88 logs "reconciliation sweep is a stub" and returns 0. Problem: with B3 and B4, there is no path by which a charge the app did not observe settling is ever reconciled. OPS §4 requires a 48 h sweep and the digest calls it mandatory because webhook delivery is weak. Fix: implement the sweep against the rail's orders/payments list API; settle or fail every in_flight intent older than the window.

B6. Nothing schedules any cron

apps/worker/src/index.ts:132-143startWorker only polls the queue. runMemberMonthlyCron, runLeadPurge and runReconciliationSweep have no caller outside tests, and no reveal_tick job is ever enqueued. Problem: members never receive a monthly photo, leads are never purged at 30 days, and deliverables.visible is never flipped. infra/docker-compose.yml:3-5 asserts the opposite as fact: "the worker owns the reveal tick, the delivery-email scheduler, the monthly member cron, the nightly lead-abandonment batch, the daily lead purge and the 48 h reconciliation sweep." None of that is true. Fix: add a tick loop to startWorker that runs each cron on its own interval, and correct the compose comment.

B7. The portal one-tap offer charges before it validates

apps/web/src/app/api/offer/route.ts:10 Problem: for oto1_prayer and replacement, guardedCharge runs first and the text validation runs after. An empty or over-cap text returns an error after a successful charge, with no prayer row, no upsell row and no totalCents update written. The settled intent then refuses the retry (allowRepeat is false for oto1), so the buyer is charged and permanently locked out of the product they paid for. Fix: move every input validation above guardedCharge. apps/web/src/app/api/oto/1/route.ts:27 already has this ordering right; the portal route inverted it.

B8. Membership pause and resume do not touch the rail

apps/web/src/app/api/portal/[token]/membership/route.ts:5

} else if (action === "pause") { /* No rail call: the adapter contract exposes no pause/resume method. */
  await repo.updateSubscription(sub.id, { status: "paused" }); }

Problem: TagadaPay's subscription engine keeps billing. Worse, runMemberMonthlyCron filters on listActiveSubscriptions(), so a paused member pays and receives nothing. The portal copy rendered beside the button reads "we never bill you for a month we did not carry a card in for" (portal/[token]/page.tsx:35). The digest states pause/resume exist on the rail API; the adapter simply omits them. Fix: add pauseSubscription/resumeSubscription to PspAdapter and the Tagada adapter. Until then, remove the pause and resume buttons rather than shipping a button that lies.

B9. Subscription creation has no idempotency guard and no rate limit

apps/web/src/app/api/membership/route.ts:28-41 Problem: INTENT_KINDS defines "subscription" (packages/payments/src/intents.ts:13) and nothing ever uses it. A double-submit creates two live rail subscriptions and two DB rows, both billing monthly. psp_subscription_id UNIQUE does not help because the rail mints two distinct ids. Second defect, same route, lines 29-34: when a ph cookie is present the route reuses existingOrder.pspInstrumentId and silently ignores the card the visitor just entered. Fix: wrap subscription creation in withChargeIntent with kind: "subscription", add the charge rate limiter, and always vault the card actually submitted on this form.

B10. Unlimited front-end charge attempts

apps/web/src/app/api/checkout/route.ts consumes no rate limiter; every OTO route does. Problem: a declined FE charge leaves orders.status = 'lead', so a ph holder can POST indefinitely with fresh card tokens. The PayFresco schedule bills $0.30 per declined attempt, and this is the classic card-testing surface that gets a merchant account reviewed. Fix: consume chargeAttemptLimiter in /api/checkout on the same per-handle counter.

B11. No Meta Conversions API, and no standard Purchase event

apps/web/src/lib/analytics.ts:35 fires fbq("trackCustom", name, props) for everything. checkout/route.ts:76 writes a purchase row with event_id: order.id that nothing ever transmits. BrandConfig.tracking.capiToken (packages/engine/src/brand-config.ts:76) is read from env into config and consumed by no code. Problem: purchase is server-side only, so the browser never fires a purchase event at all. Meta receives no purchase signal, no value, no currency, and the event_id dedupe contract is decorative. A Meta-funnel product cannot optimise or scale on this. Fix: send standard Purchase with value/currency/event_id from the browser, mirror it server-side through CAPI with the same event_id, and map the funnel events to Meta standard events where one exists.


FIX

F1. Member delivery emails re-attach every prior month

apps/worker/src/handlers.ts:338-352, apps/worker/src/repo-pg.ts:177-187 attachmentsFor does not filter on sent_at IS NULL, so month 3's email carries months 1, 2 and 3. Filter unsent deliverables.

F2. Member photos are written onto the origin order

apps/worker/src/handlers.ts:318-327 passes both orderId: sub.orderId and subscriptionId: sub.id. OPS §3 treats these as exclusive ("null on order deliveries"). Member photos then leak into the origin order's portal gallery and its delivery email. Pass orderId: null for member deliverables.

F3. One failed prayer strands the whole order

apps/worker/src/handlers.ts:51-76. When a prayer dead-letters at QA its job ends at status='failed', so siblings.every(job => job.status === "done") is false forever and the successful sibling photo is never emailed. The order sits at paid with no delivery and no alert beyond the dead-letter row. Treat failed siblings as terminal-complete for the purpose of scheduling, and flag the order for manual review.

F4. No stale-lock recovery in the queue

packages/db/src/queue.ts:41-61. A worker killed mid-job leaves the row at status='running' forever. stuckJobs reports them; nothing requeues them. Add a sweep that returns rows locked past a grace window to queued.

F5. No brand check on portal-authenticated surfaces

apps/web/src/app/portal/[token]/page.tsx:26 and apps/web/src/app/api/offer/route.ts:10 both resolve brand from the Host header and the order from the token, with no order.brandSlug === brand.slug assertion. A token issued on brand A, opened on brand B's domain, renders brand B's copy and charges brand B's prices onto brand A's order.

F6. The portal gates on revealAt, not visible

apps/web/src/app/portal/[token]/page.tsx:13 computes d.revealAt <= new Date(). OPS §3: "The portal only ever renders visible = true rows." The distinction matters for the documented goodwill-grant path, where an operator hand-inserts a row.

F7. x-forwarded-host is trusted ahead of host

apps/web/src/middleware.ts:30. Anything that reaches the app port directly picks its own brand. Behind Caddy this is contained, but the header should only be honoured from a trusted proxy.

F8. Admin token handling

apps/web/src/middleware.ts:22-28 and apps/web/src/app/admin/page.tsx:11. The token is compared with !==/=== rather than a timing-safe compare, is accepted from the query string (so it lands in Caddy access logs and browser history), and is mirrored verbatim into a cookie with no secure and no path (middleware.ts:41). Use a POST login, a timing-safe compare, and a derived session value rather than the secret itself.

F9. /thanks/correct-email is half implemented

apps/web/src/app/api/thanks/correct-email/route.ts:16 rewrites orders.email and never re-sends the confirmation email, which OPS §2 requires. It also spends the charge rate limiter (line 10), so three email corrections lock the buyer out of their own OTOs.

F10. totalCents is read-modify-written in application code

oto/1/route.ts:60, oto/2/route.ts:38, oto/3/route.ts:41, replace/route.ts:33, api/offer/route.ts:10. Two concurrent takes lose an increment. Use total_cents = total_cents + $n in SQL.

F11. /replace has no cooling-off period

apps/web/src/app/api/replace/route.ts:27 passes allowRepeat: true with only the 5/hour limiter behind it. Five posts in an hour are five $14 charges. Refuse a replacement charge within N days of the last settled one.

F12. Raw card number is posted to the server

apps/web/src/app/api/checkout/route.ts:24 and api/membership/route.ts:18 read a cardNumber form field. The PSP_ADAPTER === "mock" guard stops it being used in production, but the field is still submitted, so a real PAN reaches the Next.js request body and any request log. Remove the field from the form whenever the adapter is not mock.

F13. The webhook HMAC scheme is a guess

packages/payments/src/tagada.ts:447-449 signs ${timestampRaw}.${rawBody}. The digest documents only "HMAC-SHA256 over raw body". The dot-separated, timestamp-prefixed form is Stripe-shaped. If TagadaPay signs the body alone, every webhook fails verification on first contact. Confirm against a real delivery, or accept both forms behind a config flag, before launch.

F14. No HTTP timeout on any rail call

packages/payments/src/http.ts:87. fetch with no AbortController. A hung rail holds a Next.js request open until the platform kills it, and combined with B3 that becomes a double charge.

F15. Three of seventeen events never fire

deliverable_sent, email_open and email_click. The worker writes no events at all (no createEvent call anywhere in apps/worker/src), and there is no Resend webhook route even though packages/email/src/webhook.ts exists to parse one.


NOTE

  • apps/web/src/app/admin/page.tsx:16 loads every order for the brand and filters in JS. Fine at launch volume, a full scan later.
  • apps/web/src/app/portal/[token]/page.tsx:23 writes the live portal token into events.meta. Anyone with dashboard or DB read access sees working credentials.
  • apps/worker/package.json runs tsx src/index.ts in production. It works; it also keeps the TypeScript toolchain in the runtime image.
  • Character caps use JS .length (UTF-16 code units), so an emoji counts as two against the 220 limit. Client and server agree, so the behaviour is at least consistent.
  • handleSecret() (apps/web/src/lib/session.ts:29-38) falls back to a hardcoded dev salt outside production. Both infra/Dockerfile and infra/docker-compose.yml set NODE_ENV=production, so the guard holds — but that one env var is also the only thing disabling /api/test/state and the only thing setting the cookie Secure flag.
  • README.md says "Status: foundation only. No funnel pages exist yet." That is wrong; the whole twelve-surface funnel exists. Stale status docs cost a reviewer real time.
  • The repo has no commits. git log reports "your current branch 'master' does not have any commits yet" with everything untracked.

What is verified good

  • packages/engine/src/handle.ts — signed expiry, constant-time compare, correct failure taxonomy. Portal identity is genuinely separate from ph.
  • packages/engine/src/pricing.ts — SKU names byte-match SPEC §3; frontEndTotalCents correctly rides the candle inside the FE charge rather than making a second charge.
  • packages/db/src/queue.tsFOR UPDATE SKIP LOCKED, rush-first ordering, correct snake_case row mapping via getTableColumns (the previously reported bug is fixed and the same pattern does not recur elsewhere).
  • packages/imaging/src/qa.ts — seven checks, flame count as a DELTA not an absolute, 3 Pass-2 attempts, cost logged per call. Matches OPS §5 exactly.
  • packages/db/src/schema.ts — table-for-table against OPS §3, including the partial unique index on charge_intents and both owner check constraints.
  • Terminal-error handling in apps/worker/src/index.ts:85-103: JobAbandonedError and DonorNotApprovedError skip the retry ladder, so a QA exhaustion does not cost 9 image generations.
  • Copy fidelity is high. The guarantee string is verbatim and renders on exactly the seven required surfaces. The positioning block, ticker pool, title tag and meta description match COPY-CREATIVES.md. No em-dash outside the [REAL CUSTOMER — INSERT ONCE LIVE] marker. No AI or process language. No aggregate customer-count claim. No gift-card or reaction-video remnants.

NOT BUILT — required by OPS and absent

  1. /api/tagada/webhook route (OPS §4). Middleware excludes the path; no file exists.
  2. Resend inbound webhook route for email_open / email_click (OPS §6, §8).
  3. Real mail sending in the worker's production context (OPS §5, §6). packages/email is not a worker dependency.
  4. Any scheduler. Reveal tick, delivery-email scheduler, monthly member cron, nightly lead-abandonment batch, daily lead purge, 48 h reconciliation sweep (OPS §5, §7).
  5. Reconciliation sweep body (OPS §4) — declared stub.
  6. Fifteen of seventeen email templates are written and never triggered. Only delivery and member_delivery have a caller. Missing triggers: confirmation, in_progress, day5_resend, membership_invite, winback_buyer, replacement_365, abandoned_1/2/3, monthly_intention_reminder, dunning_1/2/3, membership_ended, member_welcome.
  7. The dunning ladder (OPS §4). No subscription/pastDue handling, no 24/48/96/168 h schedule, no membership_ended send.
  8. Subscription pause and resume on the rail (OPS §2 portal, §4).
  9. Meta CAPI server-side mirror (OPS §8). capiToken is loaded and unused.
  10. Storage client in apps/web (OPS §5 step 8). Signed URLs exist only in the worker.
  11. Dead-letter replay. apps/web/src/app/admin/page.tsx:26 renders <button disabled>Replay · not wired up yet</button>.
  12. Delivery SLA tile on the dashboard (OPS §9) — the card renders a description of what it would show, not a number.
  13. Annual plan switch and payment-method update in the portal — both render <button disabled>not available yet</button>.
  14. 3DS challenge UI. confirmAction exists on the adapter; no page ever runs startChallenge, so a requires_action outcome dead-ends with the intent left in flight.

What is fake-only, with a real implementation path

  • Image models, TTS and ambience: packages/imaging/src/fakes.ts, packages/audio/src/fakes.ts. Real adapters exist (nanobanana.ts, openai.ts, elevenlabs.ts) and are selected by env in createProductionContext. Path is real.
  • Storage: LocalDiskStorage for fakes, createStorage(env) for S3. Path is real.
  • Payments: MockPspAdapter vs TagadaPspAdapter behind PSP_ADAPTER. Path is real but untested against the live API (see F13).
  • Mail: fake only, with no selection logic in the production path (B1).

What I would verify before trusting this

  1. One live sandbox purchase end to end, then deliberately kill the network mid-charge and confirm no second charge fires on retry.
  2. A real TagadaPay webhook delivery against the HMAC scheme in tagada.ts:447.
  3. A full order on a real droplet: does an email arrive, and does the photograph render in the portal? Both currently cannot work.
  4. A paused membership across one billing cycle: confirm whether the rail bills it.
  5. Meta Events Manager: confirm a Purchase event with matching event_id from both browser and server.
  6. Two worker containers against one database, to test the maybeScheduleDeliveryEmail read-then-write race that single-worker deployment currently hides.

VERIFY PASS — 2026-09-16

Read-only re-check of B1–B11 and F1–F15 against /home/sha/vibing/prayer-engine after the three fix lanes. Test suites were not re-run (Fable ran them: typecheck clean, 276 unit, 22/22 e2e on mock and Postgres). Judged against the DECISIONS addendum: the rail is moving to Stripe behind PspAdapter, so Tagada-specific gaps are not counted unless they leak into apps/web; hosting is moving to Cloudflare Workers + D1 + R2, so Node-only dependencies in apps/web are counted.

Reviewer is Opus and the implementers were Opus. Same-model review is weak. Points where an Opus author and an Opus reviewer would likely share the blind spot are marked [shared-blind-spot risk].

Repo state: git log still reports your current branch 'master' does not have any commits yet, and git status --porcelain lists 256 untracked paths. There is no initial commit, so there is no diff to review against and no rollback point.

Item results — 17 FIXED, 8 PARTIAL, 1 NOT FIXED

# State Evidence
B1 FIXED apps/worker/package.json:16 adds the email package; apps/worker/src/context.ts:103-105 selects ResendMailAdapter unless WORKER_FAKE_MODELS=1.
B2 FIXED Portal emits /portal/{token}/asset/{id} (apps/web/src/app/portal/[token]/page.tsx:14); apps/web/src/app/portal/[token]/asset/[deliverableId]/route.ts:12-22 checks token, brand and row ownership before streaming. Raw keys never leave the server.
B3 FIXED packages/payments/src/intents.ts:213 marks needs_reconcile on a thrown charge; :150-152 refuses a second charge while that status holds. See N1 — the escape hatch does not exist.
B4 PARTIAL apps/web/src/app/api/psp/webhook/route.ts exists and verifies the HMAC, but it only resolves matching intents and writes one psp_webhook event. No refund sync, no rebill/pastDue handling, no dunning trigger, no work enqueued. See N5.
B5 NOT FIXED apps/worker/src/cron.ts:83-105 adds the sweep loop, but apps/worker/src/reconcile.ts:17-24 returns "unknown" on every path, and apps/worker/src/context.ts:106 constructs AdapterReconcileSource(null, …). Zero intents can ever resolve.
B6 FIXED apps/worker/src/index.ts:70-98 startScheduler, called at :191. Reveal 15 min, reconcile 30 min, reclaim 10 min, monthly and purge daily. Compose comment at infra/docker-compose.yml:3-5 is now true.
B7 FIXED apps/web/src/app/api/offer/route.ts:17 validates text before the charge at :23.
B8 FIXED packages/payments/src/types.ts:234-236 puts pause/resume on the adapter; the portal route calls the rail and stores the returned status. Tagada paths carry an UNVERIFIED marker (packages/payments/src/tagada.ts:415).
B9 PARTIAL Guard, limiter and always-vault are in (apps/web/src/app/api/membership/route.ts:30,36,39). See N2 (synthetic order id breaks on Postgres) and N3 (probable double charge).
B10 FIXED apps/web/src/app/api/checkout/route.ts:33 consumes chargeAttemptLimiter.
B11 PARTIAL Standard Purchase fires from the browser (apps/web/src/lib/analytics.ts:35-37, apps/web/src/components/PurchasePixel.tsx:4) and the server mirrors it (apps/web/src/app/api/checkout/route.ts:80). But both read global META_* env, BrandConfig.tracking.capiToken is still consumed nowhere, and the two sides disagree on value. See N4.
F1 FIXED apps/worker/src/handlers.ts:347 filters sentAt === null.
F2 FIXED apps/worker/src/handlers.ts:327 passes orderId: null for member deliverables.
F3 PARTIAL apps/worker/src/handlers.ts:59-63 treats failed siblings as terminal and flags the order, but maybeScheduleDeliveryEmail is only called from the success paths (:222, :266). If the failing job reaches terminal state last — the common case, since it burns three attempts with backoff — the order still strands.
F4 FIXED packages/db/src/queue.ts:136-145 requeues stale rows; wired at apps/worker/src/index.ts:57,87.
F5 FIXED Brand asserted at portal/[token]/page.tsx:27,35, api/offer/route.ts:15, asset route :16.
F6 FIXED apps/web/src/app/portal/[token]/page.tsx:12 filters on d.visible; revealAt now only picks the copy.
F7 FIXED apps/web/src/middleware.ts:29 honours x-forwarded-host only when TRUSTED_PROXY=1. Default is host-only. The var is undocumented in .env.example.
F8 PARTIAL POST login, timing-safe compare, HMAC session cookie, secure + path all landed (apps/web/src/lib/admin-auth.ts, api/admin/login/route.ts, admin/page.tsx:11). But middleware.ts:24-26 accepts any non-empty cookie, and the two admin write APIs have no auth at all. See N6.
F9 PARTIAL The charge limiter is no longer spent (api/thanks/correct-email/route.ts:7,11). The re-send it enqueues is worse than not re-sending. See N7.
F10 FIXED apps/web/src/lib/repo/postgres.ts:348-353 increments in SQL.
F11 FIXED apps/web/src/app/api/replace/route.ts:19-20 enforces one settled replacement per 30 days.
F12 FIXED (PAN) The card-number input has no name (apps/web/src/app/checkout/CheckoutForm.tsx:69-76), renders only for the mock adapter, and the server rejects the field (api/checkout/route.ts:25, api/membership/route.ts:19). The CVV was not addressed. See N8.
F13 PARTIAL The scheme moved to packages/payments/src/webhook-scheme.ts and now signs the raw body alone. It is still an unverified guess: no dual-form config flag, no live confirmation. The fix swapped one guess for another and isolated it.
F14 FIXED packages/payments/src/http.ts:68,91-92,104AbortController, 15 s default per attempt.
F15 FIXED deliverable_sent written at apps/worker/src/handlers.ts:396,422; email_open/email_click arrive through apps/web/src/app/api/email/webhook/route.ts.

New defects

N1 — needs_reconcile is a one-way trap that bricks an order's checkout. [critical] packages/payments/src/intents.ts:210-215 routes every thrown error to markNeedsReconcile, and :150-152 then refuses every later charge of that kind. Nothing can clear it: runReconciliationSweep calls a source that always answers "unknown" (B5), and the webhook cannot match the row because pspRef is null when the charge call never returned and api/psp/webhook/route.ts:18 never reads metadata.charge_intent_id. One socket hang-up permanently locks that buyer out of the product. Worse, apps/web/src/lib/charges.ts:90 catches error instanceof Error — which is every error, including AttemptLimitError and ordinary programming faults — so a plain bug in the charge path also bricks the order and shows the buyer "We are confirming this payment." [shared-blind-spot risk] — the B3 fix and this review both treat "conservative status" as safe. It is only safe with a working resolver.

N2 — guest membership signup cannot work on Postgres. [high] api/membership/route.ts:39 passes membership:${email} as orderId when there is no ph cookie. charge_intents.order_id is uuid NOT NULL with an FK to orders (packages/db/src/schema.ts:248-250), and apps/web/src/lib/repo/postgres.ts:289 filters on it, so Postgres raises 22P02 before any charge. The route then puts the raw driver message into the redirect query string (:47-48). The in-memory repo accepts the string, and the e2e suite has no /membership test, so nothing catches it.

N3 — membership signup probably charges twice on day one. [high] api/membership/route.ts:39-41 fires guardedCharge for the first period and then calls createSubscription. packages/payments/src/tagada.ts:372-389 posts the instrument and the amount with no trial and no start date; a rail that bills on subscription creation charges the member a second time immediately. Under the Stripe migration this is near-certain, since subscriptions.create with a default payment method invoices at once. The mock's createSubscription never bills (packages/payments/src/mock.ts:272-289), so no test in the suite can see this. B9's stated fix was to wrap the subscription creation in the guard, not to add a charge beside it.

N4 — the browser and server Purchase events disagree on value. [medium] The server mirror fires inside /api/checkout with the front-end amount only (api/checkout/route.ts:80); the browser fires on /thanks with order.totalCents, which by then includes every OTO (apps/web/src/app/thanks/page.tsx:31). Both carry event_id = order.id, so Meta keeps one and drops the other — normally the earlier server event, meaning OTO revenue never reaches the optimiser. Both also read global META_PIXEL_ID / META_CAPI_TOKEN, so every brand reports into one pixel and BrandConfig.tracking.capiToken stays unconsumed.

N5 — the webhook lets the payload pick its own verification key. [medium] api/psp/webhook/route.ts:11 reads the brand slug out of the request body (falling back to "st-peters"), and the per-brand webhook secret follows from that choice (packages/payments/src/factory.ts:60). The verified brand is never compared against the order the handler then mutates at :18. Anyone holding one brand's secret can resolve another brand's charge intents. Line 18 also maps any type not containing "declin" to settled, so an order/refunded delivery marks the intent settled.

N6 — two admin write endpoints have no authentication. [medium] api/admin/ad-spend/route.ts:4 and api/admin/disputes/route.ts:3 authenticate nothing. middleware.ts:22 only guards pathname === "/admin", and even there it accepts any non-empty admin_dash_auth cookie; only admin/page.tsx:11 validates the signature. Anyone can write ad-spend events (corrupting CPA) and dispute rows (corrupting the merchant-health number), with no CSRF protection.

N7 — correcting your email destroys the delivery. [high] api/thanks/correct-email/route.ts:18 enqueues a delivery_email job with payload: { template: "01-order-confirmation", resend: true }. handleDeliveryEmail (apps/worker/src/handlers.ts:360-400) ignores job.payload entirely: it sends the delivery template with whatever deliverables exist — none, this early — then calls markDeliverablesSent and setOrderStatus(order.id, "delivered"). When the photo later finishes, maybeScheduleDeliveryEmail:65-66 finds an existing delivery_email job and returns. The buyer receives an empty email, the order reads delivered, and the photograph is never sent.

N8 — the CVV still posts to the server. [medium] CheckoutForm.tsx:58,62 submit expiry and cvc as named fields. F12 removed the PAN and left the security code, so a CVV reaches the Next.js request body and any request log. CVV must never touch the merchant server.

N9 — apps/web imports node:fs, which Cloudflare Workers does not provide. [direction] apps/web/src/lib/storage.ts:1 imports mkdir, readFile, writeFile from node:fs/promises. nodejs_compat covers node:crypto (used at lib/funnel.ts:1, lib/resume-token.ts:1, lib/repo/memory.ts:1) but not fs; a static import fails at build. The same file defaults production storage to /tmp/claude-1000/prayer-storage (:89) when the S3 vars are unset. The repo also still carries apps/web/vercel.json and .vercel/, and no wrangler config.

N10 — .env.example no longer boots the worker. [low, loud failure] It documents OPENAI_API_KEY, NANO_BANANA_API_KEY and ELEVENLABS_API_KEY, but apps/worker/src/context.ts:79,105 reads OPENROUTER_API_KEY and an unprefixed RESEND_API_KEY. Both constructors throw on an empty key, so the worker dies on boot rather than running degraded. Also undocumented: WORKER_FAKE_MODELS, TRUSTED_PROXY, META_PIXEL_ID, META_CAPI_TOKEN, META_CAPI_TEST_CODE, RESEND_WEBHOOK_SECRET, DEFAULT_BRAND_SLUG, LOCAL_STORAGE_DIR, OPENROUTER_IMAGE_MODEL / _VISION_MODEL / _TTS_MODEL.

Verdict

FIX. The fix pass is real work: the portal shows photographs, the worker sends mail, the scheduler runs, the webhook endpoint exists, the timeout and the cooling-off and the SQL increment all landed. Twenty-five of twenty-six items moved.

It is not shippable yet, for four reasons. B5 was reported fixed and is not — the sweep is a loop over a data source that always answers "unknown", which is the same stub in a new shape. That turns B3's correct fix into N1, a permanent lockout with no manual override. N7 loses the delivery for any buyer who corrects their email. N3 likely bills every new member twice on day one, and no test in the suite can see it because the mock does not bill.

The passing suite says less than it appears to. The membership path (N2, N3) has no e2e case at all; the mock hides the double bill; the in-memory repo hides the uuid violation; and no test exercises a transport failure followed by a retry, which is the N1 path.

Before trusting this: one sandbox purchase with the network killed mid-charge, then a retry — confirm the buyer is not locked out. One membership signup with no ph cookie, against Postgres, then read the rail's ledger for two charges. One order where one of two photo jobs dead-letters last. One /thanks email correction followed through to whether the photograph ever arrives.