Skip to content

Discovery client attribution slice — 2026-09-19

Implemented directly in C:\Users\jefra\.codex\worktrees\yumina-discovery, branch codex/discovery-measurement. The initial slice owns only the client files below; the controller subsequently handed off the dismiss/undo sections of packages/server/src/routes/feed-events.ts and their new route test. No shared/OSS source edits, staging, commits, pushes, deployment, production access, or browser verification by this slice. Other workers' changes coexist in the worktree.

Contract and behavior

No changes to the supplied server contract. Each returned page's optional attributionToken, request ID and absolute positions stay with its original cards after append, deduplication and virtualization. Preview and picker state copy/freeze their origin. Language selection changes the mutation's target world, while discoveryAttribution: { token, worldId } still names the original exposed world. Library POST and new session POST carry this optional object. Existing-session selection and existing-game-session reuse do not issue a new-session POST.

Each client beacon retains the old fields and gets a UUID v4 eventId and ISO occurredAt at occurrence. Retries use the same serialized occurrence. play remains intent; save_intent fires before login/mutation outcomes. preview_dwell records foreground close time. No client event represents confirmed saves or confirmed starts.

PostHog mirrors the occurrence as event_id, $insert_id, occurred_at and event_source: "feed_beacon"; signed tokens are excluded from PostHog properties. Training must use the canonical server log and deduplicate mirrored copies by ID, rather than learn from both sources. Legacy pages omit the token and mutation attribution.

Bounds

ItemLimit / behavior
ImpressionAt least 50% visible for 500 continuous milliseconds while the document is visible; backgrounding resets the timer
Preview dwellForeground intervals only, monotonic clock, maximum 1,800,000 ms (30 minutes), one close event; StrictMode replay does not create a close
Beacon batchAt most 40 events and 49,152 UTF-8 bytes including JSON framing
Beacon bufferingAt most 200 events and 262,144 serialized UTF-8 bytes, including the in-flight batch
Transport concurrencyOne keepalive request at a time; other application keepalive traffic can still cause a transport rejection
FlushFour seconds, batch threshold, pagehide, or visibility becoming hidden
RetriesReceipt-bearing canonical events only: three total attempts, 4-second then 8-second retry delays for network/timeout/408/429/5xx; legacy events get one attempt because their receiver cannot deduplicate; other HTTP failures dropped
Request timeoutTen seconds, with abort and a bounded promise timeout
Event ageDiscard after five minutes; no identity/time reassignment
Local diagnosticsgetFeedBeaconStats() returns queued/sent/retried/dropped and current buffered count/bytes, without UX messages
Guest handoffSession storage, exact target-world lookup, single consumption, 30-minute expiry, maximum eight entries and 80,000 serialized characters
Handoff fieldsToken up to 24,000 characters (server receipt budget); world/request IDs up to 256; surface up to 64; position null or a nonnegative safe integer, without a 5,000 cap
Dismiss/undo requestMaximum 65,536 bytes for either JSON body; absent attribution and empty-body legacy DELETE remain supported

The queue is best effort and memory-only. A hard shutdown can lose buffered events or the final preview close, especially when a request is already in flight; no delivery guarantee or server acceptance is inferred from local counters. sent means an HTTP success, not that each individual observation was accepted for training.

Controller integration boundary

In-modal login preserves the preview origin in the existing store. Explicit guest navigation to /app/preview/:worldId saves a handoff for that selected target, retaining the original exposed card. There is no global last-touch assignment, token in a URL, or language-group fallback lookup.

The controller subsequently extended ownership to packages/app/src/features/chat/use-guest-session-start.ts and its test. Automatic creation after guest-preview login now consumes consumeDiscoveryHandoff(worldId) once for the exact target when ready, stores the immutable original-card attribution in a target-keyed ref, and adds it to the real session POST. Failed-request retries and StrictMode replay retain that snapshot without consuming replacement storage. Another target starts with its own exact lookup. Pending/successful starts ignore Retry; navigation failure reuses the resolved session ID rather than posting another creation. This closes the previously documented automatic guest-route integration gap.

Owned files

Production:

  • packages/app/src/lib/feed-beacon.ts
  • packages/app/src/lib/feed-beacon-visibility.ts (new)
  • packages/app/src/lib/discovery-attribution.ts (new)
  • packages/app/src/lib/analytics.ts
  • packages/app/src/features/hub/rec-tab.tsx
  • packages/app/src/features/hub/hub-card.tsx
  • packages/app/src/features/hub/world-preview-store.ts
  • packages/app/src/features/hub/world-preview-modal.tsx
  • packages/app/src/hooks/use-play-with-language.ts
  • packages/app/src/features/chat/use-guest-session-start.ts (subsequently authorized guest handoff integration)
  • packages/server/src/routes/feed-events.ts (dismiss/undo sections and necessary imports only; /events is controller-owned)

Tests/helpers:

  • packages/app/src/lib/feed-beacon.test.ts (new)
  • packages/app/src/lib/feed-beacon.test-helpers.ts (new)
  • packages/app/src/lib/feed-beacon-visibility.test.ts (new)
  • packages/app/src/lib/discovery-attribution.test.ts (new)
  • packages/app/src/lib/analytics.test.ts (new)
  • packages/app/src/features/hub/rec-tab.test.ts
  • packages/app/src/features/hub/hub-card.test.ts (new)
  • packages/app/src/features/hub/world-preview-store.test.ts
  • packages/app/src/features/hub/world-preview-modal.test.ts (new)
  • packages/app/src/hooks/use-play-with-language.test.ts (new)
  • packages/app/src/features/chat/use-guest-session-start.test.ts
  • packages/server/src/routes/feed-dismiss-attribution.test.ts (new)

Verification

Tests were written and observed failing before implementing the corresponding behavior. The first regression run demonstrated missing page tokens/UUIDs, mutable origins, missing retries and concurrent oversized sends. Further red runs demonstrated background impressions/dwell, absent save intent/body attribution, translation and game-start attribution gaps, stale observer entries and unbounded malformed handoff metadata.

Thirty-nine tests pass with Node's test runner and jsdom, including existing preview lifecycle/mobile-action regressions, dismiss/undo attribution, and the client review corrections. Command from packages/app:

powershell
pnpm exec tsx --tsconfig tsconfig.app.json --test src/lib/feed-beacon.test.ts src/lib/feed-beacon-visibility.test.ts src/lib/discovery-attribution.test.ts src/lib/analytics.test.ts src/features/hub/rec-tab.test.ts src/features/hub/hub-card.test.ts src/features/hub/world-preview-store.test.ts src/features/hub/world-preview-modal.test.ts src/features/hub/preview-lifecycle.test.ts src/features/hub/world-preview-mobile-actions.test.ts src/hooks/use-play-with-language.test.ts src/features/chat/use-guest-session-start.test.ts

The initial client slice passed pnpm build (5/5 tasks; Vite emitted chunk-size and mixed-import warnings), the app typecheck, edition-boundary check, and git diff --check on its owned changes. Full-worktree verification remains controller-owned while other workers continue editing.

Receipt-budget follow-up: regression tests first reproduced rejection of a signed 12,202-character, 72-card receipt and a 24,000-character boundary token. A separate read-only check with the actual server verifyDiscoveryReceipt accepted the 12,202-character fixture. Client handoff validation now matches the server's 24,000-character budget. Tests cover the exact boundary and overflow, nonnegative safe-integer positions above 5,000, storage eviction under the unchanged 80,000-character cap, and intact large receipts split across requests under the unchanged 48 KiB batch cap. All 33 targeted tests, the app typecheck and edition-boundary check passed after the adjustment. Receipt verification remains server-owned; HTTP 204 only acknowledges transport success, while transient 503 responses use the existing bounded retries. The warehouse canonical log remains authoritative; this slice does not add confirmed server events to PostHog.

Confirmed dismiss and undo follow-up

The preview snapshots discoveryAttribution: { token, worldId } before closing, then sends that same immutable original-card object in the POST /api/feed/dismiss and its delayed DELETE /api/feed/dismiss/:worldId. Opening another preview cannot reassign the Undo receipt. Undo is offered only after the dismiss request succeeds. Failed dismiss restores the visible card; failed Undo restores the hidden state and reports the existing action error. The client emits no confirmed dismissal beacon or PostHog event.

Both route mutations run inside a transaction with the controller's prepareDiscoveryOutcome(tx, rawAttribution, identity, target, "negative") and recordDiscoveryOutcome. The central helper locks and rereads the target and verifies its relation to the immutable served origin. Negative mode accepts known, creator-owned and unpublished targets. Only insert/delete RETURNING rows produce dismiss_confirmed / dismiss_undone. The agreed resource identity is discoveryEventId("dismissal", row.userId, row.worldId, row.createdAt.toISOString()); Undo uses the deleted row, and repeated no-op requests produce no extra labels. A canonical persistence failure rolls back its mutation. Invalid receipts omit the label without blocking an otherwise valid hide/undo.

The new integration suite bootstraps the guarded in-memory PGlite schema, applies the current measurement migration and history-coverage fixture, and filters negative event types so canonical page/served events do not affect its counts. No central outcome, measurement, schema, launcher or OSS files were changed by this worker. Tests were observed failing before implementation: client receipt capture and Undo failure recovery, then server confirmation, body limits and transaction rollback.

Fourteen server tests pass (seven new dismiss/undo tests plus seven existing attribution/profile regressions). Run from the worktree root:

powershell
node packages/server/scripts/test-local.mjs src/routes/feed-dismiss-attribution.test.ts src/routes/feed-dismiss-profile.test.ts src/routes/discovery-attribution.test.ts

After the client review corrections, app typecheck, edition-boundary check and scoped git diff --check pass. Full server and OSS verification remains controller-owned. The strengthened erasure check required a real wrong-user account in the dismissal receipt fixture; adding that test account restored its isolated 7/7 route run without production changes.

Independent client review corrections

Helm's independent review reproduced two defects. A lost response after a legacy insert could requeue that event, which the legacy server does not deduplicate; a batched visibility dip followed by a return could leave the earlier dwell timer running. Regressions were added and observed failing before both fixes. Mixed-batch retry filtering now retains only receipt-bearing events, preserving their IDs/timestamps while retiring legacy failures as dropped. HubCard processes every observer transition in delivery order, so a dip always resets the 500ms clock even if the final entry is visible.

The same follow-up authorized and completed the guest automatic-session integration described above. Tests cover login readiness, translated target versus original source, StrictMode, delayed failed-request retries with replacement storage, unrelated targets, abandoned navigation and completed-session reuse after navigation failure. The navigation retry regression was separately observed failing before its correction. These are client fixes authored by this worker; independent re-review belongs to Helm. No server contract or receipt/storage/batch limits changed.