Ad Network Data Layer: Multi-Angle Review + Full Fix Pass โ
Date: 2026-07-23 Branch: claude/ad-network-rotation-data-gs1sjaRelated issues: #692, #694, #695, #696, #697 Prior work: phases A-D of the ad network data layer plan (docs/planning/plans/2026-07-22-ad-network-data-layer.md, commits 4b8e0f7, b3436cd, c7eb459, 0ba8656, 4a17e86).
What happened โ
Before flipping anything on, an 8-angle background review (correctness, security/privacy, BigQuery SQL, React/UI state, API contracts, test quality, conventions, cross-cutting data flow) swept the entire ad delivery pipeline. It reported 22 verified findings; all were fixed in this pass and the review was then re-run over the fix diff.
The headline findings and their fixes โ
- Merchant identity mismatch (critical). Every merchant-scoped analytics read bound
req.user.uidasmerchant_id, but merchant identity is the mintedmerchants/{id}doc id (seegenerateMerchantIdin the auth API), linked viausers/{uid}.merchantId. The uid never matches a rollup row, so merchants would have seen permanently empty dashboards, and the web app's offers fetch 403'd against merchants-api. Fix:requireRoleattaches the linkedmerchantId,resolveMerchantScopepins merchants to it (403 with a clear message when unlinked), admins may pass?merchantId, and the web app resolvesgetOwnMerchantId()from the user doc. Recorded as decision D13. - Spoofable billing events. The tracking route stamps client submissions
metadata.attributionSource='client_untrusted'; claims/redemptions are billing-adjacent and must originate server-side, so the rollup MERGE now excludes client-untrusted rows for those two event names only (fills, impressions, and clicks are client-observed by nature). Decision D15. - Truncation-blind totals / suppressed-cell leak surface. Totals were summed client-side over a LIMIT-capped, gated row list. Now a second SQL aggregate computes totals over ALL matching cells with the k-anon floor applied per cell (
SUM(IF(unique_users >= @floor, col, 0))), plus a suppressed-cell count; the row list carriestruncatedwhen capped. Decision D14. - Cross-user dedupe bleed. The fill/impression dedupe window survived sign-out, so a second user on a shared device inherited "already counted" keys.
noteAdTelemetryUser(uid)(called from App.jsx's auth listener) resets the window on uid change. - Self-healing aggregation. Scheduled MERGE default lookback moved from 1 to 3 days; the MERGE target is day-bounded for partition pruning; the admin backfill body is strictly validated (
lookbackDaysinteger 1-365, unknown keys rejected). Decision D16.
Plus 17 smaller fixes: click emitters migrated to trackAdClick (full attribution payload), stale merchant-portal state resets on merchant switch (Overview band, OffersList delivery map), RFC 4180 CSV quoting, loader try/finally so the page can't strand on "loading", one shared formatter/floor-constant module (@lantern/shared/ads/delivery plus apps/admin/.../dashboardFormat.js) replacing three local copies, zod schema and PLACEMENTS dedupe across routes, METRIC_EVENTS built from the canonical AD_EVENTS constants with a registry-membership test, a 60s client cache on delivery fetches, emoji error banners replaced with AlertTriangle, row keys including merchant_id, and em dash glyphs replaced with en dashes (five files dropped from the ratchet baseline, none added).
Verification โ
- Analytics API: service + route specs updated for the two-query fetch, gated totals, minted-id scoping, admin
?merchantId, and the 403 unlinked path. - Web: CSV quoting spec (including the quote-doubling case), telemetry user-switch spec, delivery service specs against the async auth shape.
npm run validatefull suite green before push; openapi.json and the plan doc runbook updated to match the shipped behavior.
Round 2: the re-review (8 fresh angles over the fix diff) โ
The re-run came back with a second wave of verified findings, all fixed in the follow-up commit (decisions D17-D20 in the plan doc):
- Environment tag mismatch (critical, dev-blocking). Dev Cloud Run deploys set
NODE_ENV=production, soresolveEnvironment()filteredenvironment='production'against rows the forge stamps'development': every BQ dashboard would read zero rows on dev, silently. Reads now derive the tag from the project id exactly like the write side. (D17) - Unproxied web path. The new
/api/merchants/{id}/offersfetch matched no proxy route in_worker.jsor the Vite dev proxy, fell through to the SPA fallback, and soft-failed to "no offers" forever. Both proxies now route/api/merchants. - NULL-stamp trust filter.
NOT (...='client_untrusted')also dropped NULL-stamped claim/redeem rows via SQL three-valued logic. Rewritten as a NULL-safe fail-closed allowlist (attributionSource='server_authoritative'), and the attribution contract now requires that stamp + user_id from the future server-side claim flow. (D18) - Dedupe-window wipes. The first auth callback of a page load (and a guest converting to an account) cleared the fill/impression dedupe keys, re-introducing the #692 inflation for pre-auth fills; the window now clears only when a DIFFERENT signed-in user appears.
- Cross-merchant cache leak. The 60s delivery cache was keyed by path alone; merchant B on a shared browser inside the TTL could be served merchant A's numbers. Cache keys now carry the merchant scope, and the Ad Delivery dashboard's Refresh forces a real fetch (it previously served the cache while stamping "Updated: now"); range switches drop stale data so old numbers can't sit under new labels, and truncated coverage counts label themselves as a floor. (D20)
- Click-only slots. Feed (AdSlot shim) and venue-detail emitted clicks but never FILLED/IMPRESSION, creating impossible cells that inflate CTR. Both slots now emit the full fill / in-view impression / click triplet, and the venue page credits placement 'inline' rather than polluting hero cells. (D19)
- Test gaps closed: rbac merchantId attach, the merchant service fetches (minted-id URL + SPA-fallback soft-fail), truncation flag true-path, outage dashes (never fabricated zeros), and a full route spec for the strict backfill body. One accepted gap: no App-level integration test renders the real auth listener to assert the telemetry user-note wiring (unit-tested at module level; App harness cost not justified).
Follow-ups โ
- Flip-on is now automatic: the dev deploy workflow creates the rollup table and arms the
analytics-ad-delivery-dailyscheduler job on the first analytics-api deploy after merge (idempotent; plan doc section 4b). The remaining runbook steps are verification only. - Claim/redeem server-side flow (#697) is the natural next theme; the trust filter and attribution contract are already in place for it.