2026-07-22: Analytics trust model + test-leak guard (#596, #315) โ
What shipped โ
- Service-attributed user events (the #596 trust-model decision). Forge's validator previously forced every event to be EITHER a user event OR a service event. The original lanterns-api call sites sent both and were silently dropped; the June fix (#603) made them land by dropping
serviceId, which left server rows indistinguishable from client-reported rows. Decision recorded here: both together is now a valid shape, a trusted backend reporting an action a user took.user_idkeeps per-person aggregation;service_idis the unforgeable server-signed marker (clients cannot set it: the analytics-api route zod-strips unknown fields and never forwards one). - Admin metrics count trusted rows only. The three bqMetrics builders (overview, venue rankings, active-venue count) now count
lantern_lit/wave_*only whereservice_id IS NOT NULL. A browser can no longer move venue rankings by fabricating flash events (the M-ENTITYID data-integrity half).active_usersintentionally still counts all events: it is an activity signal, not a spoof-sensitive ranking. - Test-leak guard (#315). Forge's BigQuery/Firestore writers refuse real writes under a test runner (
VITEST/NODE_ENV=test) unlessFORGE_ALLOW_TEST_WRITES=true. This is what stops fixtures (user-1,venue-123,v1) from ever reaching the real events table again, which is how #315's junk triplets got into BigQuery. Forge's own SDK-mocked pipeline tests opt in explicitly; two new tests pin the guard's behavior. - Contract tests extended: the lanterns light/wave service-attributed shape is pinned so the silent-drop class cannot recur.
What this means for the metrics โ
Historical rows are untouched: pre-existing server rows have service_id = NULL (the June fix stripped it), so admin lantern/wave counts effectively restart from this deploy for the trusted filter. On dev, with pilot-scale data, this is an accepted reset; the alternative (backfilling trust onto old rows) cannot distinguish old server rows from old client rows and was rejected.
Operator checklist (BigQuery, needs your credentials) โ
- After the deploy, light a lantern on dev, then:
SELECT event_name, user_id, service_id, entity_id FROM lantern-app-dev.analytics.events WHERE event_name='lantern_lit' ORDER BY timestamp DESC LIMIT 5Expect the new row to carry BOTH your uid andservice_id='lanterns-api'. - One-time junk cleanup (the #315 leftovers):
DELETE FROM lantern-app-dev.analytics.events WHERE user_id IN ('user-1','user-123') OR entity_id IN ('venue-123','v1','venue-1')(Run a SELECT first to eyeball the rows; they are all test fixtures.) - Confirm the admin Venue Activity dashboard shows counts again once fresh server-signed events accumulate.
Pre-PR adversarial review findings (applied) โ
wave_accepted/wave_declinedhave NO server-signed producer (client-only via WaveManager), so the overview counts them unfiltered; a trusted filter would have flatlined them at zero. Server-signing them when accept/decline moves server-side is #691.- The
event_counts_dailyrollup is raw volume (includes untrusted client pings) and will read higher than the trust-filtered overview; documented at the registry entry, alignment options tracked in #691. - Service-attributed events are exempted from forge's per-user rate budget (they carry route-level limits); previously a burst of client
venue_viewedcould silently starve the very server-signed rows the admin metrics count. - Stale "forge rejects both" comment in assistant
chat.jsupdated to the new contract.
Deferred (recorded) โ
- Idempotency keys / client-stamped event ids for pipeline-level dedup: folded into #613's scope (client-stamped event context), noted there.
- Ownership-promotion of client entity attribution (
client_trustedon merchant match): already tracked in the route's M-ENTITYID note (#270).