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-143 — startWorker 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:16loads 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:23writes the live portal token intoevents.meta. Anyone with dashboard or DB read access sees working credentials.apps/worker/package.jsonrunstsx src/index.tsin 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. Bothinfra/Dockerfileandinfra/docker-compose.ymlsetNODE_ENV=production, so the guard holds — but that one env var is also the only thing disabling/api/test/stateand the only thing setting the cookieSecureflag.README.mdsays "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 logreports "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 fromph.packages/engine/src/pricing.ts— SKU names byte-match SPEC §3;frontEndTotalCentscorrectly rides the candle inside the FE charge rather than making a second charge.packages/db/src/queue.ts—FOR UPDATE SKIP LOCKED, rush-first ordering, correct snake_case row mapping viagetTableColumns(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 oncharge_intentsand both owner check constraints.- Terminal-error handling in
apps/worker/src/index.ts:85-103:JobAbandonedErrorandDonorNotApprovedErrorskip 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
/api/tagada/webhookroute (OPS §4). Middleware excludes the path; no file exists.- Resend inbound webhook route for
email_open/email_click(OPS §6, §8). - Real mail sending in the worker's production context (OPS §5, §6).
packages/emailis not a worker dependency. - Any scheduler. Reveal tick, delivery-email scheduler, monthly member cron, nightly lead-abandonment batch, daily lead purge, 48 h reconciliation sweep (OPS §5, §7).
- Reconciliation sweep body (OPS §4) — declared stub.
- Fifteen of seventeen email templates are written and never triggered. Only
deliveryandmember_deliveryhave 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. - The dunning ladder (OPS §4). No
subscription/pastDuehandling, no 24/48/96/168 h schedule, nomembership_endedsend. - Subscription pause and resume on the rail (OPS §2 portal, §4).
- Meta CAPI server-side mirror (OPS §8).
capiTokenis loaded and unused. - Storage client in
apps/web(OPS §5 step 8). Signed URLs exist only in the worker. - Dead-letter replay.
apps/web/src/app/admin/page.tsx:26renders<button disabled>Replay · not wired up yet</button>. - Delivery SLA tile on the dashboard (OPS §9) — the card renders a description of what it would show, not a number.
- Annual plan switch and payment-method update in the portal — both render
<button disabled>not available yet</button>. - 3DS challenge UI.
confirmActionexists on the adapter; no page ever runsstartChallenge, so arequires_actionoutcome 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 increateProductionContext. Path is real. - Storage:
LocalDiskStoragefor fakes,createStorage(env)for S3. Path is real. - Payments:
MockPspAdaptervsTagadaPspAdapterbehindPSP_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
- One live sandbox purchase end to end, then deliberately kill the network mid-charge and confirm no second charge fires on retry.
- A real TagadaPay webhook delivery against the HMAC scheme in
tagada.ts:447. - A full order on a real droplet: does an email arrive, and does the photograph render in the portal? Both currently cannot work.
- A paused membership across one billing cycle: confirm whether the rail bills it.
- Meta Events Manager: confirm a
Purchaseevent with matchingevent_idfrom both browser and server. - Two worker containers against one database, to test the
maybeScheduleDeliveryEmailread-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,104 — AbortController, 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.