Skip to content

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_id keeps per-person aggregation; service_id is 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 where service_id IS NOT NULL. A browser can no longer move venue rankings by fabricating flash events (the M-ENTITYID data-integrity half). active_users intentionally 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) unless FORGE_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) โ€‹

  1. 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 5 Expect the new row to carry BOTH your uid and service_id='lanterns-api'.
  2. 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.)
  3. Confirm the admin Venue Activity dashboard shows counts again once fresh server-signed events accumulate.

Pre-PR adversarial review findings (applied) โ€‹

  • wave_accepted/wave_declined have 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_daily rollup 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_viewed could silently starve the very server-signed rows the admin metrics count.
  • Stale "forge rejects both" comment in assistant chat.js updated 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_trusted on merchant match): already tracked in the route's M-ENTITYID note (#270).

Built with VitePress