Skip to content
Ritesh FirodiyaGet in touch

Work / Chitragupt / Wiki / Decisions

2026-07-06-v1-launch-blockers

Decisioncanonicalverified 2026-07-06

DECISION.2026-07-06.V1-LAUNCH-BLOCKERS

V1 launch is BLOCKED — 26 correctness bugs surfaced by end-to-end flow audit

Decision

V1 is not launchable as of 2026-07-06. A 6-agent parallel end-to-end audit of the upload → review → pillar pipeline surfaced 26 launch-blocking bugs and ~15 must-fix HIGH issues across every stage. The ROADMAP claim that "Phases 0–7 are done" is contradicted by the code.

.context/features/index.md remains directionally accurate for architecture and status, but the "Status: DONE" tags on Phases 2, 3, 4, 5, 6, and 7 are provisional — they describe code that was written, not code that provably works end-to-end. This decision downgrades them to "Code-complete, verification-failed" until the blockers below clear.

Why

Six flow-tracing audit agents (Inbox lifecycle, Tax build, Expense build, Portfolio build, CA marketplace + payout, Auth + Payment + Family) ran in parallel against main. Each was told to find breaks in the end-to-end flow, not to code-review. A second independent CA-marketplace audit corroborated Phase 5 findings.

Three of the 26 blockers are individually release-halting:

  1. Every Form 16 user pays ~₹15,700 extra tax. packages/shared/src/rules/v1/income-summary.ts:37-38 maps standard_deduction and professional_tax positive-paise entries into other_income_paise. The tax-computer also subtracts standard deduction from the AY table. Net effect: taxable income overstated by ~₹52k for anyone with a Form 16.
  2. Every CA earns ₹0. apps/functions/src/triggers/firestore/on-consult-request-written.ts:64-75 mints engagement docs without price_paise. on-ca-engagement-closed.ts:54 falls back to 0. Weekly payout batch computes fee/TDS/net all zero.
  3. Nobody can buy Pro Family. apps/functions/src/subscription/unlock.ts:67-80 requires family_membership.role==head before allowing the unlock. createFamily only runs AFTER verify. Chicken-and-egg wedge on onboarding and Self→Pro Family top-up.

Impact

🔴 Launch blockers (26)

Ingest / Inbox (4)

# File:line One-line failure
B1 apps/functions/src/inbox/backfill-free-uploads.ts:99-115 Backfilled Free-tier docs get temporal=null → confirmDocument rejects for every category except other. Free→paid upgrade cannot unlock old uploads.
B2 apps/functions/src/inbox/backfill-free-uploads.ts:99-115 Backfill uses bare parseDocument() instead of runParserAndPersist() → skips identity extraction, owner validation, LLM fallback. Two ingest paths that disagree.
B3 apps/functions/src/_lib/dedup.ts (entire file) Dedup engine is orphaned — no writer calls findDedupLinks. Same Form 16 uploaded 5× → 5 confirmed docs → 5× ledger fan-out → tax review double-counts salary. Three of the 14 documented statuses (superseded, rejected_duplicate, pending_conflict_resolution) have zero producer.
B4 apps/functions/src/inbox/backfill-free-uploads.ts:99-115 No MAX_INGEST_BYTES guard → 700MB PDF slipped past client cap OOMs 1 GiB backfill worker → drain loop stalls forever.

Mappers (2)

# File:line One-line failure
B5 apps/functions/src/triggers/firestore/ledger-mappers/index.ts:62-98 No credit-card mapper registered. LLM classifier advertises credit_card as a valid sub-form; user confirms; mapper returns []. Silent zero rows for CC statements.
B6 apps/functions/src/triggers/firestore/ledger-mappers/crypto-statement.ts:16 Emits entry_type: "vda_realised_gain". income-summary.ts:58 reads "vda_gain_net". Crypto entries land in unmapped_paise, never taxed.

Tax compute (2)

# File:line One-line failure
B7 packages/shared/src/rules/v1/income-summary.ts:37-38 standard_deduction + professional_tax mapped as positive other_income_paise → double-counted → ~₹15,700 over-tax at 30% slab. Every Form 16 user affected.
B8 packages/shared/src/rules/v1/tax-computer.ts (whole file) No crypto_vda_paise reference in computeTax. §115BBH 30% flat never applied. A user with ₹5L VDA gain sees tax_paise=0.

Schema drift (1)

# File:line One-line failure
B9 packages/shared/src/schemas/tax-review.ts:95-102 FILING_STATUSES still allows draft/exported/filed/refund_credited/balance_paid. Decision 2026-05-31-self-filing-removed says only not_filed/itr_v_acknowledged. Stale states will re-appear if anyone reads schema for docs.

Portfolio (3)

# File:line One-line failure
B10 apps/functions/src/portfolio/portfolio-position-mapper.ts:89-94 Broker-CG mapper synthesizes a fake position with cost_basis=0, current_value=realised_pnl_total. Pollutes NAV, asset-allocation donut, YTD, unrealised. Broker-CG is a tax report, not a holdings statement — should emit ledger only.
B11 apps/functions/src/tax/capital-gains-engine.ts:78-79 Unrealised bucket lands entirely in unrealised_long_term_paise because V1 cannot split short/long without per-lot tenure. Combined with B10, UI displays "unrealised LTCG" that isn't LTCG.
B12 packages/shared/src/schemas/portfolio-trade.ts:6-9 References apps/functions/src/portfolio/trade-attribution.ts — file does not exist. portfolio_trades collection is subscribed by client but has no writer.

Expense (2)

# File:line One-line failure
B13 apps/functions/src/expense/ (entire directory) No writer creates expense_anomalies/{id}. Only mutators exist (dismiss, dispute). open_anomaly_count is always 0, AnomalyReviewPanel never renders, classifyExpenseReview never returns "anomaly".
B14 apps/website/src/app/(app)/expense/components/ExpensePage.tsx:614-621 Anomaly action handlers (onConfirm, onMarkRecurring, onStartDispute) are empty /* TODO: wire … in follow-up */. AnomalyDisputeModal is exported but never imported anywhere → dead orphan.

CA marketplace + payout (11)

# File:line One-line failure
B15 apps/functions/src/triggers/firestore/on-consult-request-written.ts:64-75 Engagement minted without price_paise → on-ca-engagement-closed.ts:54 reads undefined ?? 0 → payout-line gross=0 → fee/TDS/net all 0. Every CA paid ₹0 on every consult.
B16 apps/functions/src/admin/disputes.ts:71-82 adminResolveDispute never clears is_held on the payout line. Even on pay_ca verdict, line stays frozen forever. Also: refund_client verdict performs no Razorpay refund.
B17 apps/functions/src/triggers/firestore/on-dispute-opened.ts:21 escrow_payout_week_id never written by any callable — dispute-open trigger reads it, but the if (weekId) freeze branch is dead code. TDS is deducted and CA is paid despite active dispute.
B18 apps/functions/src/admin/recompute-kpis.ts:128 Held-line KPI queries where("status", "==", "held") but trigger writes is_held: true. Admin dashboard shows 0 held → cannot see the leak from B17.
B19 apps/functions/src/_lib/payout-artifacts.ts:79 NEFT CSV ACCOUNT column is intentionally blank. No Indian bank's bulk-upload template accepts this. Weekly payouts cannot be dispatched.
B20 apps/functions/src/marketplace/engagement.ts:180 requestConsult has no idempotency key. Double-tap → two Razorpay orders + two consult docs.
B21 apps/functions/src/marketplace/ca-share.ts:37 shares primitive lacks unique invite-token guarantee → parallel inviteCa calls with same email mint duplicate share rows; revokeCaShare only deletes one.
B22 apps/functions/src/admin/payouts.ts:116-149 adminRunPayoutBatch uses batch id YYYY-MM-DD; cron uses YYYY-Wnn. Two parallel ID spaces — admin-created batches never reconcile with cron-created lines.
B23 (no file) No openCaDispute / fileCaDispute callable exists. onDisputeOpened fires on disputes/{id} create — nothing lets a client create that doc. Dispute flow has no first step.
B24 apps/functions/src/ca/profile.ts:99-107 + apps/functions/src/admin/ca-applications.ts:210-232 payout_account_ifsc / payout_account_last4 declared on ca_profiles but no callable writes them. Every payout line ships null. Compounds B19. Form 16A / 26Q print "BANK ****0000".
B25 apps/functions/src/admin/ca-applications.ts:198-204 Admin approval requires pricing on the application. submitCaApplication (onboarding.ts:81-99) does NOT collect pricing (moved to post-approval /ca/portal/pricing). updateCaPricing requires role:"ca", set only post-approval. No CA can be approved through the wired-up flow.
B26 apps/functions/src/subscription/unlock.ts:67-80 Pro Family requestUnlock refuses non-head-uid. createFamily runs AFTER verifyUnlockPayment. Chicken-and-egg → nobody can buy Pro Family from onboarding. Same wedge on Self→Pro Family top-up for anyone who never created a family.

🟠 HIGH (edge-case wrong-number, must fix before launch)

  • describeRecommendation "roughly the same" threshold is ₹1,000 not ₹100 — misleads on ₹800 regime savings (packages/shared/src/rules/v1/orchestrator.ts:314).
  • Duplicate-guard on requestUnlock is read-then-write, not atomic — double-tap can create two Razorpay orders (apps/functions/src/subscription/rate-limit.ts:26-54).
  • Razorpay webhook event dedupe is read-then-write, not atomic — concurrent retries can double-dispatch backfill + email (apps/functions/src/triggers/webhook/razorpay-webhook.ts:107-114).
  • Portfolio page hardcodes STCG 20% / LTCG 12.5% / ₹1.25L (drifts for past-AY view) (apps/website/src/app/(app)/portfolio/components/PortfolioPage.tsx:765-767).
  • ITR-1↔ITR-2 auto-switch not persisted on tax_reviews/{ay} — only called at CA-hire time (apps/website/src/utils/itr-form.ts). ROADMAP Phase 4 claim is only half-met.
  • Tax YoY chart labels gross_salary_paise as "Income" — omits CG/rental (apps/functions/src/tax/history.ts:60).
  • isPastAy uses URL comparison instead of shared predicate — future AY 2027-28 shows "free past AY" banner (apps/website/src/app/(app)/tax/use-tax-view-model.ts:78).
  • Surcharge capital_gains_cap_pct (15% CG cap) documented but never applied — over-surcharges users >₹50L with LTCG (packages/shared/src/rules/v1/tax-computer.ts:194).
  • Surcharge band edge uses > on lower bound → income of exactly ₹50L drops one paise-edge case (packages/shared/src/rules/v1/tax-computer.ts:333-347).
  • foreign-remittance + professional-receipt parsers ship with zero fixture tests.
  • verifyConsultPayment uses !== string compare, not timingSafeEqual (apps/functions/src/marketplace/engagement.ts:283).
  • CA pricing has no upper bound in NonNegativePaiseSchema — accidental extra zero goes live (packages/shared/src/schemas/ca-application.ts:31-45).
  • session-storage pending-verify banner shows on unrelated pages after abandoned Pro Family purchase (apps/website/src/components/payment/useUnlockCheckout.ts:105-117).
  • Weekly batch flips to status:"verified" with total_gross=0 when all lines are within refund window → held lines stranded forever (apps/functions/src/triggers/scheduled/weekly-payout-batch.ts:155-164).
  • adminReleasePayout sets status:"pending" but never deletes is_held:true → released lines stay frozen (apps/functions/src/admin/payouts.ts:105-111).
  • downloadCaForm16A collectionGroup query has no AY filter → wrong quarter cert can be served (apps/functions/src/ca/form-16a.ts:33-46).
  • Bank statement mapper returns [] on statements without transactions array — silent zero rows, no user signal (apps/functions/src/triggers/firestore/ledger-mappers/bank-statement.ts:54-56).
  • on-consult-request-written.ts:85 — year_max derivation Number.parseInt(ay.split("-")[0]) is fragile against AY-format drift.
  • Schema drift amount_paise vs claim_amount_paise on disputes — resolver reads one, trigger reads the other (apps/functions/src/admin/disputes.ts:19-22 + apps/functions/src/ca/earnings.ts:19-22).

Path forward

  1. Now: capture this ADR (this file) and mark ROADMAP Phase status as "Code-complete, verification-failed" pending fixes.
  2. Next: run Phase B audit (Security + DPDP, App Check exhaustiveness, Payment race conditions, Money discipline invariants, Wiki-drift /sync-check, Firestore-rules coverage, Cloud-Function memory/timeout sizing, PII redaction, Perf hot paths). Expected to surface 5-15 additional items.
  3. Then: fix in priority order. Rough estimates:
    • Tax income double-count (B7) + crypto (B6, B8): ~1 day
    • CA marketplace (B15-B25, 11 items): ~4-5 days
    • Inbox backfill + dedup (B1-B4): ~2 days
    • Portfolio (B10-B12): ~2 days
    • Expense CC + anomaly (B5, B13, B14): ~2 days
    • Pro Family precondition (B26): ~0.5 day
    • HIGH items (~15): ~3-4 days
    • Total: ~2.5-3 focused engineering weeks.
  4. Only after re-audit passes → resume ROADMAP Phase 7 SLA gates (Lighthouse, parser <4%, payment-recon drain, grievance SLA).
  5. Only then the ROADMAP "Soft launch (24 h observability)" checklist can begin.

Phases affected: roadmap Phase 2 (Inbox), Phase 3 (Tax), Phase 4 (Expense + Portfolio), Phase 5 (CA marketplace), Phase 6 (Pro Family), Phase 7a-e (hardening). All previously marked DONE — verification failed on every one except foundational auth/DPDP.

Status

Active.

Sources


Phase B addendum — 18 additional findings (non-functional audit, 2026-07-06)

A 10-lens Phase B audit workflow ran after Phase A: App Check, Firestore rules, Storage rules + lifecycle, Payment race conditions, DPDP compliance, Money discipline, Wiki drift, Perf hot paths, Copy voice, Parser robustness. Each finding was adversarially verified (a second agent instructed to refute the claim by default). Synthesis stage and 37 of ~55 verifiers hit the session token cap — the 18 findings below are those that landed with full adversarial confirmation. Additional un-verified findings remain to be re-run.

Two new blockers add to the launch-halting list:

  • P27 (grievance/file.ts:139) — anonymous DPDP-grievance endpoint stores client-supplied storage_path verbatim without validating the intake prefix. An attacker with a valid App Check token can register users/{other_uid}/vault/form-16.pdf as evidence; admin viewing the case can mint signed URLs for arbitrary bucket files. Cross-user PII leak, unauthenticated attacker.
  • P28 (subscription/activate-unlock.ts:58) — concurrent upgrade + refund race. Buy Self ₹499 → open AI-Self upgrade (₹250 delta quoted) → refund Self → complete ₹250. User ends with AI Self for net ₹250 instead of ₹749. Same race gives Pro Family for net ₹550.

🔴 BLOCKER (2)

# File:line Lens Failure
P27 apps/functions/src/grievance/file.ts:139 storage-rules-and-lifecycle fileGrievance stores client-supplied storage_path without moving intake blob or validating the path — The comment at firebase/storage.rules:171 promises "fileGrievance moves successful uploads under grievances/{caseId}/evidence/ and deletes the rest." The actual implementation at grievance/file.ts:139-150 records storage_path: item.storage_path verbatim on grievances/{caseId}/evidence/{...} and never touches the file. The path is not validated to live under grievances/_intake/**, so an anonymous caller can register users/{other_uid}/vault/form-16.pdf or payouts/{weekId}/artifacts/neft.csv as evidence. Admin, who has read: if isAdmin() on every path in the tree, can then click through the case in the desk UI and open a signed URL for any file on the bucket via a follow-on admin flow. Even without exfiltration, evidence blobs stay in _intake/ forever with no sweep.
P28 apps/functions/src/subscription/activate-unlock.ts:58 payment-race-conditions Concurrent upgrade + refund race lets user unlock AI Self / Pro Family after refunding the underlying tier — User buys Self on AY 2025-26 for ₹499. They open the AI-Self upgrade dialog — requestUnlock quotes the ₹250 delta (existing Self present) and mints a Razorpay order. Before completing checkout, they file a refund of the original Self via requestRefund; it auto-approves (within 14 days, no exports) and stamps refunded_at on the Self entry. They now complete the ₹250 payment. activateUnlockInTransaction's filter at line 58-64 drops any AY entry whose rank < upgradedRank — the refunded Self is discarded — and appends a fresh ai_self entry. The user ends up with AI Self unlocked, having paid a net ₹250 (₹499 refunded − ₹250 topup captured − actually collected ₹250) instead of the ₹749 sticker. Same race with pro_family_from_self_topup gives Pro Family for ₹550 net-of-refund. There is no re-check of alreadyUnlocked (or of the refunded_at state that quoted the delta) inside the activation transaction, and refund.ts has no guard against active pending_payment topup orders for the same AY.

🟠 HIGH (9)

# File:line Lens Failure
P29 apps/functions/src/subscription/activate-unlock.ts:58 payment-race-conditions Same-tier double payment produces duplicate ay_unlocks entries with no compare-and-swap on plan — H1 duplicate-guard on requestUnlock only blocks same (ay, plan) within a 5-minute window. If a user requests two Self unlocks for AY 2025-26 more than 5 minutes apart (e.g. abandoned first checkout, resumed via a bookmarked Razorpay link, or the payment-stuck reconciler flow), two orders exist. Both eventually capture (rare but possible — customer support has been known to nudge a stuck payment through). activateUnlockInTransaction only filters ay_unlocks entries where subscriptionPlanTierRank(bought_as) < upgradedRank (strictly less-than), so the pre-existing Self entry survives and a second Self entry is appended. The subscription doc now shows two Self rows with two razorpay_payment_ids for the same AY, activatedAmount on the email sends ₹499 twice, and the refund UI is ambiguous about which payment to reverse. The activation should short-circuit when an entry with rank(bought_as) >= upgradedRank && refunded_at == null already exists for the AY.
P30 apps/functions/src/triggers/scheduled/weekly-payout-batch.ts:39 payment-race-conditions Weekly payout cron has read-then-write status gate — overlapping retry double-counts totals and re-generates NEFT CSV — Cloud Scheduler at-least-once retries (or a manual admin re-run) can invoke weeklyPayoutBatch twice for the same weekId. The guard at line 39 checks batchSnap.get('status') !== 'scheduled' outside any transaction, and the terminal batchRef.update({ status: 'verified', total_gross_paise, ... }) at line 155 is a plain update with no CAS. Run A starts, reads scheduled, begins iterating lines; run B starts 30 s later, also reads scheduled (A has not written yet), begins iterating. Both flip per-line status to verified (per-line is safe — the line.status === 'verified' short-circuit at line 62 helps whichever runs second on a given doc), but each independently accumulates totalGross/totalFee/totalTds against whatever subset it wins the race for. Two disjoint partial totals get written to the batch doc in an unspecified order; the KPI dashboard reports whichever landed last. Worse, generateAndPersistAllBatchArtifacts(weekId) runs twice — the NEFT CSV and Form 26Q are re-generated and re-uploaded to storage, and if the admin has already downloaded the first CSV and bulk-uploaded it to the bank, the second run's totals no longer match the money that actually moved. Wrap the guard + line iteration + totals write in runTransaction with a status CAS, and short-circuit generateAndPersistAllBatchArtifacts on re-entry.
P31 apps/functions/src/triggers/webhook/razorpay-webhook.ts:258 payment-race-conditions Multiple payment attempts per Razorpay order can leave a second successful capture unrefunded — Razorpay orders can accept multiple payment attempts (a failed card retry followed by a successful UPI; or a legitimate user error where two payments land on the same order). Both would emit payment.captured events with different payment.id but the same order_id. First event runs, transaction flips status: verified and stamps razorpay_payment_id: paymentA. Second event runs — line 258 sees status === 'verified' and returns; NO auto-refund of paymentB is triggered, NO alert is logged, and the second payment silently sits captured on Razorpay with no linkage in our system. The user is out ₹499 (or ₹749/₹1499) and support has to reconcile from the Razorpay dashboard. Emit a compensating auto-refund (or at minimum an admin-queue alert) when a payment.captured for an order we've already reconciled arrives with a different payment_id.
P32 apps/functions/src/ca/earnings.ts:111 perf-hot-paths listMyCaPayouts reads every payout batch ever + one line-doc per batch — unbounded, grows weekly forever — The CA-portal /earnings page calls listMyCaPayouts. Line 111 does collection("payouts").get() with no where/orderBy/limit, so it returns every weekly batch since launch. Then lines 112-129 fan out Promise.all reads for payouts/{week}/lines/{ca_uid} — one read per batch, whether or not this CA participated. After 1 year that's 52 batch docs + 52 line reads per page load per CA (~104 reads); after 3 years ~312 reads. Latency climbs monotonically forever and Firestore bills scale linearly. Should be bounded to the last N weeks (or filtered by collectionGroup("lines").where(FieldPath.documentId(), "==", ctx.uid)).
P33 apps/functions/src/family/overview.ts:265 perf-hot-paths getFamilyExpenseOverview pulls each member's ENTIRE ledger with no AY/date filter, then filters by month client-side — Line 265-268 issues collection("users/{member}/ledger_entries").where("entry_class", "in", ["expense"]).get() for every household member, then loops in JS to keep only entries whose txn_date starts with the current YYYY-MM- prefix. A family of 5, each with 24 months of bank + card statements (~2,000 expense entries/member), fans out to 10,000 doc reads on every dashboard visit even though the view only needs the current month. Callable ~500 ms→3 s and Firestore bill scales with vault age. Should filter by txn_date >= monthStart and < monthEnd in the query.
P34 apps/functions/src/_lib/callable.ts:190 copy-voice-error-messages Every callable input error leaks Zod jargon verbatim to end users — A user submits a form with a bad value (e.g. missing ay). onZodCall throws HttpsError('invalid-argument', Invalid input: ${issues.map(i => ${i.path.join('.')}: ${i.message}).join('; ')}), which bubbles through presentError (apps/website/src/_lib/logger.ts:62) and lands in the UI as a toast/banner reading e.g. Invalid input: amount_paise: Expected number, received string; ay: Invalid enum value. Expected '2025-26' | '2026-27', received ''. This violates the copy rule ('error messages say what to do next, not what failed internally'); the user has no idea what to do.
P35 apps/functions/src/subscription/razorpay-orders.ts:58 copy-voice-error-messages Razorpay HTTP failures dump vendor name + status + response body straight into the user-facing error — When Razorpay's create-order endpoint returns a non-2xx (rate limited, bad keys, downstream 502), the callable throws Razorpay order creation failed: ${res.status} ${body.slice(0, 200)}. useUnlockCheckout → presentError surfaces this string in the payment error banner. A paying customer sees Razorpay order creation failed: 502 {"error":{"code":"BAD_REQUEST_ERROR"...}} — leaks vendor identity, mentions raw HTTP codes, and gives no next step. Same pattern at apps/functions/src/marketplace/engagement.ts:211.
P36 packages/parser/src/broker-cg/parser.ts:140 parser-robustness-edge-cases broker-cg parser uses parseFloat on money values, violating money-in-paise invariant — Zerodha Tax P&L exports emit rupee values as 4-decimal floats (e.g. 12345.6789). parseDataRows calls parseFloat on qty/buy/sell/profit/taxable/fmv (lines 140-146), sums them as floats in sumColumn, then multiplies by 100 and rounds. On a P&L with 500 short-term trades, IEEE-754 float error compounds to ~₹0.50–₹5 aggregate drift in stcg_equity/ltcg_equity; that value feeds directly into income-summary.ts → tax-computer at slab rate. Directly violates .claude/rules/money.md ('parseFloat is banned in a money calculation'). Fix: use parseMoneyToPaise per cell and sum in integer paise.
P37 packages/parser/src/bank-statement/parser.ts:114 parser-robustness-edge-cases bank-statement parser persists masked-but-length-revealing account number to Firestore fields blob — Line 114 stores fields.account_number = { value: maskAccountNumber(m[1]), ... }. maskAccountNumber pads with X's to the original digit count, so a 14-digit HDFC A/C surfaces as 'XXXXXXXXXX3456' in the persisted documents/{id}.fields doc. extract-identity.ts:91 derives account_no_last_4 from this — but the raw account_number field STAYS in the fields blob (persisted at run-and-persist.ts:413) alongside ifsc (full). Combined with IFSC + digit-count, this narrows account uniqueness in a way DPDP data-minimisation forbids. Fix: overwrite fields.account_number to last-4 or strip before persist.

🟡 MEDIUM (6)

# File:line Lens Failure
P38 apps/functions/src/subscription/unlock.ts:213 payment-race-conditions drainBackfill fires from both verifyUnlockPayment and reconcileUnlock — two workers race the same view_only backlog — verifyUnlockPayment (line 213) and reconcileUnlock in the webhook (line 278) both void drainBackfill(uid).catch(...) after activation. In the common case where the browser calls verifyUnlockPayment AND Razorpay delivers order.paid within the same 1-2 s window, TWO backfill workers run concurrently. runBackfillBatch (backfill-free-uploads.ts:70) queries status == 'view_only' limit 26 — both workers grab overlapping doc sets, both download the same PDFs from Storage, both call parseDocument (the expensive step), both write writeDpdpAudit({event_type: 'free_upload_parsed', ...}). Net effect: 2× Storage egress, 2× parser cost, and duplicate DPDP audit rows for every backfilled doc. Not a correctness bug, but a real cost bug and an audit-log pollution problem. Guard with a lock doc (users/{uid}/backfill/current) or serialize by making the webhook path the only drainer.
P39 apps/functions/src/subscription/razorpay-orders.ts:32 copy-voice-error-messages 'Razorpay keys are not configured' shipped as user error copy — If prod is misconfigured (secret rotation dropped RAZORPAY_KEY_ID), createUnlockOrder throws HttpsError('failed-precondition', 'Razorpay keys are not configured'). The paying user sees the vendor's brand + our internal config state instead of Payments are temporarily unavailable — please try again in a few minutes. Same class of leak at apps/functions/src/subscription/refund.ts:120/136/166/178 ('Could not validate payment record — please contact support' says what broke internally, not what to do).
P40 apps/website/src/components/inbox/InboxRightRail.tsx:70 copy-voice-error-messages Inbox right rail uses bare §80CCD(1B)/§80C jargon with no plain-English fallback — Suggestion card reads Claim ₹50k under §80CCD(1B) on top of §80C. A first-time salaried filer (the stated audience) does not know either code. Per CLAUDE.md's Copy rule — '§80C alone is jargon; 80C tax-saving investments (PPF, ELSS, LIC) is readable' — this needs the plain expansion. Same pattern at apps/website/src/components/ask/AskTranscriptDemo.tsx:97/111/121, apps/website/src/components/ask/AskEmptyState.tsx:65, apps/website/src/app/(auth)/sign-in/SignInValuePanel.tsx:13 (₹15,600 unclaimed under §80C), apps/website/src/utils/expense-category-pill.ts:42, apps/website/src/components/expense/CategoriseModal.tsx:34, apps/website/src/components/expense/ExpenseUncategorisedAside.tsx:47, apps/website/src/app/(app)/expense/transactions/components/ExpenseTransactions.tsx:326/491.
P41 apps/functions/src/subscription/unlock.ts:53 copy-voice-error-messages 'AY 2024-25 is a past year — unlocked for free' framed as an error the user must dismiss — When the user tries to pay for a past AY, requestUnlock throws HttpsError('failed-precondition', AY ${data.ay} is a past year — unlocked for free). This surfaces as a red error banner via presentError, telling a user their action succeeded (they don't need to pay) but styling it as a failure. The message also uses 'AY' jargon. Correct behaviour is a success/info toast, not an error copy line.
P42 packages/parser/src/classify.ts:399 parser-robustness-edge-cases professional-receipt classifier requires a header shape freelance invoices frequently lack — Weights: 'Tax Invoice' 0.4 + 'Particulars HSN/SAC' 0.3 + 'Export of Services' 0.3 + specific 'PAN...GST' header 0.2. Real self-issued consultant invoices frequently omit 'Tax Invoice' header (under GST composition or below threshold), or use 'Bill' / 'Invoice' — score caps at 0.5 → falls under the 0.7 floor → classified 'unknown' → LLM fallback runs → for deploys without ANTHROPIC_API_KEY, silently lands in needs_classification. Compounds with the zero-fixture coverage already noted. Fix: add lower-weight fallback anchors ('Bill To' + HSN table alone) or require an issuer-side PAN + GST as the strong signal.
P43 packages/parser/src/foreign-remittance/parser.ts:95 parser-robustness-edge-cases foreign-remittance txn_date matcher grabs the first month-day-year on the page — mdyMatch on line 95: \b([A-Z][a-z]+)\s+(\d{1,2}),\s+(\d{4})\b — matches any 'Month Day, Year' on a Wise/Torc/Payoneer PDF. These emails commonly open with 'Chitragupt Ltd. copyright March 15, 2020 – present' footer or a 'reference generated on ' line. The date-search fires at first match → wrong txn_date → foreign-remittance is event-keyed so AY is derived from this — wrong FY → income lands in wrong AY. No fixture exists to catch this (parser has zero fixture coverage). Fix: anchor after 'Paid on' / 'Payment date' / 'Received on' label OR restrict to date-adjacent-to-amount lines.

⚪ LOW (1)

# File:line Lens Failure
P44 firebase/storage.rules:172 storage-rules-and-lifecycle grievances/_intake write rule accepts anonymous uploads gated only by App Check — no per-IP quota — match /grievances/_intake/{intakeId}/{allPaths=**} allows write: if hasAppCheck() && imageOrPdf() && sizeUnderMb(5). fileGrievance has an IP rate-limit of 5 grievances/hour but the storage upload runs BEFORE fileGrievance, so an attacker with a valid App Check token can upload unlimited 5 MB files under fresh intakeIds without ever calling fileGrievance. Combined with the missing lifecycle policy (finding #1), a single attacker with a browser can fill the bucket by opening the grievance form in loops. Storage rules cannot enforce IP quotas; V1 needs a Cloud Function checker or bucket lifecycle to time-out un-adopted intake dirs (e.g., delete after 24 h if no matching grievances/{caseId} exists).

Updated blocker count: 28 (26 Phase A + 2 Phase B). Updated HIGH count: ~24 (15 Phase A + 9 Phase B). 6 MEDIUM + 1 LOW added.

Phase B execution note

  • 10 finder agents ran successfully (App Check + role gates lens returned zero findings — a good sign; Firestore rules coverage, DPDP compliance, Wiki drift did not report before session cap).
  • 18 findings adversarially verified with full refutation-attempt.
  • 37 verifiers and the synthesis stage hit the session token cap (resets 7:30 PM IST 2026-07-06); these need re-run to close out coverage on the remaining ~35 raw findings not shown here.
  • Re-run command: Workflow({scriptPath: "/Users/rsf/.claude/projects/-Users-rsf-conductor-workspaces-chitragupt-accra/1fbc6812-de82-4859-bd7d-312797eee722/workflows/scripts/v1-phase-b-audit-wf_a30784d1-561.js", resumeFromRunId: "wf_a30784d1-561"}) — completed finders return cached; only failed verifiers + synthesis re-run.

Fixes landed 2026-07-06 (all 28 blockers)

All Phase A + Phase B blockers have landed on branch production-readiness-audit-v1 (PR #29). Commits are one-per-blocker so the fix scope for any single item is easy to review. Verification: full yarn typecheck + hard-rule lint + full test suite green (829 tests across shared / parser / mobile / functions).

# Commit Blocker
B7 b52b4345 fix(tax): stop counting standard_deduction + prof_tax as income
B15 33129327 fix(marketplace): stamp price_paise on engagement so CA payout is non-zero
B26 cceb0d8a fix(family): auto-provision head-family on Pro Family unlock
P27 52e35b72 fix(grievance): validate + relocate storage_path prefix
B6 2c2d4c04 fix(tax): align crypto ledger entry_type
B9 b52d4291 fix(tax): narrow FILING_STATUSES to not_filed / itr_v_acknowledged
B18 993736ab fix(admin): held-line KPI reads is_held not status
B21 f393e935 fix(marketplace): dedup inviteCa on (owner, email, relationship)
B22 7f65bdfe fix(marketplace): unify payout batch id space on YYYY-Wnn
B8 1d72f268 fix(tax): apply §115BBH 30% flat tax to crypto VDA gains
B10 1cc07614 fix(portfolio): broker-CG emits ledger only, no synthetic position
B11 5673c81f fix(portfolio): zero the unrealised buckets until per-lot lands
B12 e34d29f0 fix(portfolio): remove portfolio_trades orphan
B4 062a41c9 fix(inbox): oversize guard on the Free-tier backfill worker
B20 fa20451b fix(marketplace): idempotency guard on requestConsult
B19 + B24 316a2e28 fix(marketplace): plumb full payout account number for NEFT CSV
B25 00f16767 fix(marketplace): approve CA without pricing, gate marketplace until set
B23 + B17 cb0d5ff2 feat(marketplace): openCaDispute callable + escrow week writer
B16 ef3cdd7b fix(marketplace): dispute resolution unfreezes payout line + queues refund
P28 95b98091 fix(payment): refuse refund while an upgrade for the same AY is in flight
B1 + B2 d8fb0c10 fix(inbox): backfill delegates to runParserAndPersist
B3 7c764a4f fix(inbox): wire dedup engine into ingest for exact-fingerprint tier
B5 2dfb2197 fix(inbox): classify CC-payment narrations as transfers
B13 + B14 d991922c refactor(expense): defer anomaly detection to V3

What still needs to happen before launch

  1. Deploy + observe. Every fix that changed a compute path (tax income double-count B7, crypto tax B8, portfolio synthesis B10, engagement price_paise B15) invalidates prior computed reviews. Recompute cascade + one-off recomputeTaxReview / recomputePortfolioReview passes need to run for existing paid users after the deploy lands.
  2. Data migration. CAs need to re-enter payout account numbers via the CA portal (the field didn't exist before B24 landed).
  3. Phase B re-run. ~37 verifier stages + the synthesis stage of the Phase B workflow hit the session cap on 2026-07-06 — those need to re-run to close out audit coverage on the remaining raw findings.
  4. Runtime SLA gates. ROADMAP Phase 7 gates (Lighthouse a11y ≥ 95 on 7 launch surfaces, parser failure < 4%, payment-recon queue drain, DPDP grievance SLA at 100%) need to be measured in prod, not asserted.
  5. CA marketplace end-to-end dry run. Every path in Phase 5 that had a blocker in this audit deserves a live-fire test: apply → approve → set pricing → hire → verify → dispute → resolve → payout → Form 26Q + 16A + NEFT CSV. Book a soft-launch pass with 1 admin-approved CA + 1 test client before opening the marketplace to real users.

No-legacy refactor sweep (commit 2027ede3, 2026-07-09)

The pre-launch codebase carries no customer data, so every "keep old shape for compatibility" concession from the initial fix commits has been unwound. 72 files changed (+220 / -1957); test suite green (829 tests). Highlights:

Type tightening.

  • crypto_vda_paise is required in ComputeTaxInput (was optional to preserve back-compat with tests). Every test call site now passes it explicitly.
  • Dispute resolver reads only amount_paise; the claim_amount_paise fallback and its consumers in ca/earnings.ts have been deleted.

Dead code deletion (data model).

  • packages/shared/src/schemas/expense-anomaly.ts + anomaly-dispute.ts deleted. Backend dismissAnomaly / openAnomalyDispute / attachAnomalyDisputeEvidence / submitAnomalyDispute callables deleted along with the associated bank-forms PDF renderer and grievance-PDF renderer.
  • Client useExpenseAnomalies store hook + AnomalyReviewPanel + AnomalyDisputeModal + the entire components/expense/dispute-modal/ folder deleted. Mobile equivalents dropped from expense-pillar.ts / stores/expense.ts.
  • expense_reviews schema drops open_anomaly_count; the anomaly variant is removed from ExpenseVariant and its UI branches (mobile + web).
  • packages/shared/src/schemas/document-conflict.ts deleted. The pending_conflict_resolution DocumentStatus is removed from the enum and its UI branches (DocumentRow, StatusBanner, document-list helpers, mobile inbox screens).

Ledger correctness.

  • Form-16 mapper no longer emits standard_deduction / professional_tax entries at all. Standard deduction is applied by the tax-computer from the AY table; professional tax is not modelled as a separate deduction. Prior fix left them landing in unmapped_paise; this deletes them from the ledger entirely.

Dedup engine.

  • Simplified from 3 tiers to 2 (exact_duplicate + supersedes). Tier-3 "conflict" required UI to resolve and had no writer; removed with the DocumentConflict schema. pending_conflict_resolution status deleted from the enum + every UI consumer.
  • Tier-2 (supersedes) is now wired through the ingest trigger with real period_start / period_end / account_id axes. The superseded doc is stamped status: "superseded" + superseded_by; the new doc carries supersedes.

Helper extraction.

  • apps/functions/src/_lib/iso-week.ts replaces four inline copies of the ISO week-id function (weekly-payout-batch, on-ca-engagement-closed, admin/payouts, marketplace/engagement).

Comment purge.

  • Every "see 2026-07-06-v1-launch-blockers B##" citation removed from source comments. Comments now explain what the code does / why the invariant holds, without linking back to the incident that motivated the fix — that context lives in git history + this ADR.

Verification: yarn typecheck && yarn lint && yarn test all green; Ledger tests + Firestore rules tests unchanged.

Every project of mine is written down like this.

Read the résumé