Work / Chitragupt / Wiki / Decisions
2026-07-06-v1-launch-blockers
Decisioncanonicalverified 2026-07-06
DECISION.2026-07-06.V1-LAUNCH-BLOCKERSV1 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:
- Every Form 16 user pays ~₹15,700 extra tax.
packages/shared/src/rules/v1/income-summary.ts:37-38mapsstandard_deductionandprofessional_taxpositive-paise entries intoother_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. - Every CA earns ₹0.
apps/functions/src/triggers/firestore/on-consult-request-written.ts:64-75mints engagement docs withoutprice_paise.on-ca-engagement-closed.ts:54falls back to 0. Weekly payout batch computes fee/TDS/net all zero. - Nobody can buy Pro Family.
apps/functions/src/subscription/unlock.ts:67-80requiresfamily_membership.role==headbefore allowing the unlock.createFamilyonly 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
requestUnlockis 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_paiseas "Income" — omits CG/rental (apps/functions/src/tax/history.ts:60). isPastAyuses 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-receiptparsers ship with zero fixture tests.verifyConsultPaymentuses!==string compare, nottimingSafeEqual(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-storagepending-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"withtotal_gross=0when all lines are within refund window → held lines stranded forever (apps/functions/src/triggers/scheduled/weekly-payout-batch.ts:155-164). adminReleasePayoutsetsstatus:"pending"but never deletesis_held:true→ released lines stay frozen (apps/functions/src/admin/payouts.ts:105-111).downloadCaForm16AcollectionGroup 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 withouttransactionsarray — 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_maxderivationNumber.parseInt(ay.split("-")[0])is fragile against AY-format drift.- Schema drift
amount_paisevsclaim_amount_paiseon 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
- Now: capture this ADR (this file) and mark ROADMAP Phase status as "Code-complete, verification-failed" pending fixes.
- 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. - 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.
- Only after re-audit passes → resume ROADMAP Phase 7 SLA gates (Lighthouse, parser <4%, payment-recon drain, grievance SLA).
- 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
- 6-agent parallel Phase A audit run 2026-07-06 (Inbox / Tax / Expense / Portfolio / CA-marketplace / Auth-Payment-Family flow tracing).
- 1-agent independent CA-marketplace re-audit 2026-07-06 (corroborated Phase 5 findings + surfaced B22-B25).
- Findings verified against
main@ 5f1b7234 (2026-07-05 parser fix commit). - Related concepts / entities: upload-only, read-only-review, money-in-paise, pillar-tax, pillar-expense, pillar-portfolio, pillar-marketplace, tier-self, tier-pro-family.
- Related decisions: 2026-05-31-self-filing-removed, 2026-06-28-phase-7a-app-check-enforcement.
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_pathverbatim without validating the intake prefix. An attacker with a valid App Check token can registerusers/{other_uid}/vault/form-16.pdfas 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 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
- 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.
- Data migration. CAs need to re-enter payout account numbers via the CA portal (the field didn't exist before B24 landed).
- 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.
- 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.
- 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_paiseis required inComputeTaxInput(was optional to preserve back-compat with tests). Every test call site now passes it explicitly.- Dispute resolver reads only
amount_paise; theclaim_amount_paisefallback and its consumers inca/earnings.tshave been deleted.
Dead code deletion (data model).
packages/shared/src/schemas/expense-anomaly.ts+anomaly-dispute.tsdeleted. BackenddismissAnomaly/openAnomalyDispute/attachAnomalyDisputeEvidence/submitAnomalyDisputecallables deleted along with the associated bank-forms PDF renderer and grievance-PDF renderer.- Client
useExpenseAnomaliesstore hook +AnomalyReviewPanel+AnomalyDisputeModal+ the entirecomponents/expense/dispute-modal/folder deleted. Mobile equivalents dropped fromexpense-pillar.ts/stores/expense.ts. expense_reviewsschema dropsopen_anomaly_count; theanomalyvariant is removed fromExpenseVariantand its UI branches (mobile + web).packages/shared/src/schemas/document-conflict.tsdeleted. Thepending_conflict_resolutionDocumentStatus 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_taxentries 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 inunmapped_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
DocumentConflictschema.pending_conflict_resolutionstatus deleted from the enum + every UI consumer. - Tier-2 (supersedes) is now wired through the ingest trigger with
real
period_start/period_end/account_idaxes. The superseded doc is stampedstatus: "superseded"+superseded_by; the new doc carriessupersedes.
Helper extraction.
apps/functions/src/_lib/iso-week.tsreplaces 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é