Skip to content

ADR-0003: DM identity & persistence — session_id as the unique id, per-type episodes, the three DM axes, and the audit-PK fix

ADR-0003: DM identity & persistence — session_id as the unique id, per-type episodes, the three DM axes, and the audit-PK fix

Section titled “ADR-0003: DM identity & persistence — session_id as the unique id, per-type episodes, the three DM axes, and the audit-PK fix”
  • Status: Proposed (2026-06-22)
  • Deciders: Ali Siddiqi (DM stack), with mobile (iOS) + Growth (notification-service) + PS-6 (PLT-681) as required reviewers
  • Affects: PD-1 (PLT-685), PD-2 (PLT-686), PD-3 (PLT-751), PS-6 (PLT-681), ios-fetch-rewards, consumer-agent (episode builder)
  • Relates to / amends: ADR-0001 (gateway-direct dispatch), CGW #212 (presentation/surface design)

Issues surfaced reviewing the DM stack before team review, verified against the live legacy code, the iOS app, and the FSD table definition (not just docs):

Issue 1 — the new dispatch path dropped episode creation

Section titled “Issue 1 — the new dispatch path dropped episode creation”

The legacy Weekly Forecast handler delivers a DM in two backend calls (internal/notification/weeklyforecast/adapter.go + template.go):

  1. caClient.CreateEpisode(userID, episodeReq) → returns episode_id (consumer-agent’s episode builder — a separate service from notification-service).
  2. POST /v1/gateway/notify with the episode_id wired into the push:
    • metadata["episode_id"] = episodeID
    • Content.Data["deeplink"] = "fetchrewards://ai_assistant?subAction=episode&subActionValue=<episodeID>"

ADR-0001 made PD-1 dispatch go gateway-direct. In doing so the new dispatch step (internal/dm/dispatch/) implements only call #2 — it never calls CreateEpisode. So a DM fired through the new pipeline would have no episode, which means:

  • No episode_id → the chat deeplink can’t even resolve (iOS requires a non-empty subActionValue; empty → the deeplink fails to parse), and
  • No read/unread, no FAB unread badge — that state lives entirely in the episode layer (PLT-372, live in prod), keyed on the episode id.

This corrects an earlier team assumption that episodes are created only for conversation-initiating DMs. They are not: the consumer-agent episode builder creates an episode for every DM, including non-conversational pushes (weekly forecast, repurchase nudge). is_unread is defined precisely as “created by the episode builder and not yet opened.” Episode-per-DM is the existing, required mechanism — the new pipeline regressed it.

Issue 2 — notif_type, presentation, and destination are conflated

Section titled “Issue 2 — notif_type, presentation, and destination are conflated”

Mobile (verified in ios-fetch-rewards) currently overloads the chat deeplink to mean three different things at once:

  • DirectMessageHandler.handle classifies a notification as a DM iff its deeplink contains subAction=episode — so “is this a DM” is inferred from the destination.
  • AssistantDomainDeepLinkRequest routes the tap on the deeplink scheme (ai_assistant?subAction=episode → chat; the subActionValue episode_id is required).
  • There is no presentation concept at all — a DM just expands the FAB into a preview.

These are distinct concerns the current shipped contract collapses onto one string (the deeplink). The plan already separates them: presentation_format (mobile presentation + new-system classification) and the deeplink (destination) become distinct fields, with notif_type staying the backend type. (Terminology note: legacy mobile uses “DM” to mean the conversational/custom_ai_assistant presentation specifically; the backend uses it loosely for any fired message. This doc avoids the bare word “DM” and names the concern precisely.)

Issue 3 — the unique-per-DM key, and a latent audit-PK assumption

Section titled “Issue 3 — the unique-per-DM key, and a latent audit-PK assumption”

The audit table ({env}-dm-schedule-events, FSD) keys on PK=dm_run_id, SK=user_id, with Snowflake dedupe key event_id = dm_run_id:user_id. That assumes one fire per (run, user).

For every DM type we are actually shipping, that assumption holds:

  • Cron / Weekly Forecast: the candidate query returns one row per user, and N shelf items collapse into one shelf DM. One fire per (run, user).
  • Earnings / Post-Scan Coach: the POE contract is per-receipt, at-most-one-tip (TopK: 1selected_count: 0|1). The per-item A1/A2 candidates are generated, ranked, and selected inside the POE; PD-1 sees one scan event → one candidate → 0 or 1 DM. One fire per (run, user).

So (dm_run_id, user_id) is unique in practice today. The only thing that would break it is a future DM type that emits N same-user candidates as N separate delivered DMs in one run — which no current or contracted type does (PD-1 §4.3’s “0..N item candidates” always collapse to one DM at copy). We note it as a latent assumption, not a live bug.

The robust unique id regardless: session_id is a fresh uuid4 minted per fire (per candidate → FireEvent) in both firing modes (cron.go, kafkamode.go). dm_run_id is one-per-run; session_id is one-per-DM, with no caveat.

0. session_id is the unique identifier for a delivered DM

Section titled “0. session_id is the unique identifier for a delivered DM”

session_id (fresh uuid4 per fire, both modes) is the canonical unique per-DM key — 1:1 with a fire, unconditionally. dm_run_id is the run-grouping key (a cron tick / Kafka message). Analytics correlate a delivered DM on session_id; funnel rollups group by dm_run_id. (dm_run_id, user_id) is also unique for every shipping type (Issue 3) and remains the audit table’s key (§C) — but session_id is what downstream consumers should treat as the DM id, because it stays unique even if a future type fans out.

Note on episode_id vs session_id. For inbox DMs the episode_id is also a unique per-DM id (one episode per fire) and is what read/unread keys on (§A/§B). But episode_id exists only for inbox DMs (§A2), and only after the episode-builder call succeeds, whereas session_id exists for every fire from the moment it’s minted. So session_id is the universal id; episode_id is the inbox/read-unread id when present.

A. PD-1 dispatch creates the episode (amends ADR-0001)

Section titled “A. PD-1 dispatch creates the episode (amends ADR-0001)”

The dispatch step becomes a two-call sequence, restoring what legacy does:

stage 7 dispatch (per fire):
1. consumer-agent CreateEpisode(bff_payload) → episode_id [NEW — currently missing]
2. notification-service POST /v1/gateway/notify
Content.Data["deeplink"] = <destination URL, carrying episode_id when chat-routed>
metadata["episode_id"] = episode_id

ADR-0001’s “no SQS, gateway-direct” decision stands — this does not reintroduce a queue. It only acknowledges that “dispatch” was always two downstream services (episode builder

  • gateway), and the new path must call both, as legacy did.

Failure ordering (mirror legacy): create the episode first, with bounded retry; only on a created episode do we POST the gateway. A gateway failure after a created episode leaves an unread episode the user still sees on app open (acceptable — the episode is the durable artifact). A CreateEpisode failure fails the fire (recorded in the audit; re-fireable). This is the legacy ErrShelfEpisodeFailed posture.

A2. Episode creation is PER-TYPE (inbox vs. transient), not unconditional

Section titled “A2. Episode creation is PER-TYPE (inbox vs. transient), not unconditional”

Episode-per-DM is not an unconditional pipeline step — it’s a declared per-type behavior. The episode buys exactly one thing: presence in the assistant inbox (the FAB unread badge + App Open re-surfacing), because that inbox is the episode layer (PLT-372). It does not provide uniqueness — session_id does that (§0). So:

  • Inbox DMs (inbox: true) — Weekly Forecast, repurchase nudge, conversational coach: create an episode; episode_id drives read/unread + the FAB badge + the chat deeplink.
  • Transient DMs (inbox: false) — fire-and-forget pushes that should not clutter the assistant inbox (e.g. a Post-Scan Coach push that deep-links to an offer page): create no episode. Verified safe: a push with no episode_id delivers + displays as an ordinary banner — the gateway has no episode_id gate, and iOS DirectMessageHandler simply returns false (not classified as a DM) and shows it normally. Identity/analytics use session_id.

This is a new PD-3 field (inbox: true|false, default true to preserve current behavior). It directly resolves the “is creating an episode for every DM just incumbency?” question: for inbox DMs the episode is load-bearing; for transient DMs it would be incumbency, so we don’t.

Prior art: the per-type-vs-classifier question was raised but left open in #proj-direct-messages (2026-02, Steve Hollinger ↔ Sajan Shrestha): mobile asked for an explicit “is this a DM” field rather than inferring it from the deeplink; Steve noted the caller already enforces a granular message type (“more granular than DM, e.g. repurchase nudge”) and would design the interface. See Open Question 1.

B. Three concerns, the right field for each

Section titled “B. Three concerns, the right field for each”

notif_type (backend type identity), presentation_format (mobile presentation + new-system classification), and the deeplink (destination) are distinct concerns carried by distinct fields. They are not fully orthogonal — presentation_format deliberately does double duty (classify by presence + select surface by value, with custom_ai_assistant implying a conversation/episode). The point is that each concern has the right home, not that there are three independent toggles.

ConcernFieldMeaningOwnerStatus
Type identitynotif_type (= PD-3 type_id, verbatim)what this message is (scan-coach, app-open, weekly-forecast) — drives audit, funnel, analyticsbackendlive, unchanged
Presentation + classificationpresentation_formathow mobile presents it: small_toast / large_toast / fst / modal / custom_ai_assistant. Its presence classifies a message as new-system (vs. legacy/Iterable); custom_ai_assistant is the conversational value (implies an episode + chat surface).mobile contractproposed (in-progress on mobile)
Destinationdeeplink (push) + cta_action (episode card)where a tap goesbackend (URL string)live mechanism

Decisions:

  • notif_type stays the backend type identity, verbatim. scan-coach, app-open, weekly-forecast. It is not repurposed for UI surface — overloading it would break the funnel/audit joins that key on type identity. This is the backend-facing field; the funnel’s join key lives here and nothing about it changes.
  • presentation_format is the mobile presentation enum AND the classifier. Values: small_toast | large_toast | fst | modal | custom_ai_assistant. This is what mobile reads to pick the surface. Its presence is also how mobile classifies the message as new-system (handled by FetchNotificationHandler vs. falling back to legacy Iterable — see below); custom_ai_assistant is the value that means “conversational” (episode + chat presentation), i.e. mobile’s legacy notion of a “DM”. The four others are non-conversational surfaces.
  • These are two separate fields, not a rename. notif_type = granular backend type (for analytics); presentation_format = mobile presentation/classification. The iOS NotifType enum (custom_ai_assistant/small_toast/large_toast/modal) shows the presentation vocabulary currently rides the notif_type wire field; the plan moves that vocabulary to presentation_format and returns notif_type to the granular backend type. So there is a coordinated cutover: backend starts emitting presentation_format; mobile switches its dispatcher from reading notif_type to reading presentation_format.
  • Absent presentation_format = legacy/Iterable handling, NOT a notification-service banner. Verified in iOS AppDelegate+RemoteNotifications.swift: a push not claimed by FetchNotificationHandler falls through to Iterable (Messaging.shared...) on tap. So a message with no presentation_format is handled by the legacy stack, not rendered as a new-system banner. (This corrects an earlier draft that said it would deliver as a normal notification-service banner.) There is intentionally no push_only value — it was misleading, since the real “no new-system treatment” case is the absent-field Iterable fallback, not a distinct enum value.
  • Extensibility for other teams. Recommended path: use one of the four default presentation formats (small_toast/large_toast/fst/modal). If a team genuinely needs custom handling, they introduce a custom_feature_X value and implement the matching custom handler on the mobile side. Defaults unless a new surface is truly required, with a clear contract for when it is.
  • Destination stays in the deeplink / cta_action — the live mechanism. The backend sets the destination by writing a URL string into Content.Data["deeplink"] (the tap target) and the episode’s cta_action (the feed-card button). No new transport; legacy already does this. Constraint: the URL scheme/path must be one iOS’s DeepLinkManager already routes.

C. Audit-table keys: keep the base key, add query GSIs

Section titled “C. Audit-table keys: keep the base key, add query GSIs”

The base key stays PK=dm_run_id, SK=user_id and event_id = dm_run_id:user_id. Per Issue 3 that is unique for every shipping type, so no destructive re-key is needed for v1. What we add now is additive (no table rebuild), addressing real operational query needs:

  • Query-by-user GSI (user-fire-time-index): PK=user_id, SK=fire_time → “all DMs for a user, newest first.” user_id is already the base SK, but a user-PK GSI lets you query a user’s history across all runs without knowing dm_run_id.
  • Query-by-session GSI (session-index): PK=session_id → look a delivered DM up by its canonical id (§0) alone. Needed because mobile analytics / support will hold a session_id, not a (dm_run_id, user_id) pair.

Add session_id as a projected attribute on these so a session lookup returns the full row.

Deferred (optional future hardening): if a future DM type ever emits N separate DMs for the same user in one run (no current/contracted type does — Issue 3), the base key would need to move to SK=session_id (and event_id = dm_run_id:session_id) to avoid last-write-wins collisions. That’s a destructive change, free only while the table is dark. We are not doing it now (no type needs it); it’s tracked here as the migration to make if and when a per-DM-fan-out type is proposed — at which point it should be its own decision, not a speculative change to a hypothetical.

  • presentation_format must be persisted onto the episode/BFF object (PS-6 owns the schema), not only the transient NotifyRequest — otherwise App Open can’t re-render an unread DM with the right surface (#212’s core finding). It also rides the push payload for the live render.
  • session_id persisted onto the episode (analytics correlation), so engagement events can join back to the exact fire. (dm_run_id too, as the run-grouping key for funnel rollups.) Today the episode carries neither.

These are additive fields on CreateEpisodeRequest + the episode object — a PS-6 (PLT-681) ask, the gating cross-team dependency.

E. The Coach maps onto the pipeline as POE-enricher + near-passthrough downstream

Section titled “E. The Coach maps onto the pipeline as POE-enricher + near-passthrough downstream”

The POE contract is a synchronous decision engine: PD-1 sends the scan event to POST /v1/coach/handle, the POE hydrates its own ~31 features, generates A1/A2 candidates, ranks, and returns 0 or 1 tip (it does not deliver — rendering + dispatch is PD-1’s job). So the Coach maps onto the existing pipeline as:

  • Stage 2 enrich = the POE call (the ADR-0002 ref-resolved enricher; one receipt-level candidate in → the candidate decorated with the tip, or “no tip”). This is where Frank’s “it’s calling an engine, not enriching” observation lands — but it’s still hosted by the generic enrich stage; no stage rename needed.
  • Stage 3 eligibility ≈ “did the POE return a tip?” (drop the fire on selected_count: 0).
  • Stage 4 ranking = none — the POE already selected the single winner (TopK: 1).
  • Stage 5 copy renders the returned tip; stage 7 dispatches one DM.

i.e. the Coach is 1 candidate → POE decides → 0/1 DM. The per-candidate eligibility/ranking machinery (which Weekly Forecast uses for its shelf) is near-trivial for the Coach because the POE owns selection. No new pipeline shape — the existing stages absorb it, with the engine call sitting in the enrich slot.

Depends on finalizing the POE response payload (contract §3, Option A — extend the HTTP response to return the ScoredCandidate/ReasonGraph fields PD-1 needs to render). Until that’s agreed, the enricher is the [STUB] pass-through already in earningscoach.PoeEnricher.

The presentation/classification work (mobile-led, in-progress):

  1. Classify + select surface on presentation_format. Today DirectMessageHandler.handle classifies by the subAction=episode deeplink heuristic, and the dispatcher reads the presentation vocabulary off notif_type. The plan: read presentation_format — its presence means new-system handling (vs. legacy/Iterable fallback when absent), and its value (small_toast/large_toast/fst/modal/custom_ai_assistant) selects the surface. Read it from the push payload (live render) and the episode object (App Open re-render). notif_type reverts to the granular backend type for analytics only.

  2. (Separate, later batch — NOT this work) Decouple the FAB unread count from the popup presentation. Make the FAB unread count react to any episode deeplink, independent of the FAB popup presentation_format. This was not needed for the Coach and is explicitly its own batch, called out here so it is tracked, not folded into the presentation_format work.

Backend side (this stack): start emitting presentation_format on the push payload + the episode (today CGW emits only notif_type), and keep notif_type as the verbatim type. The presentation-vocabulary move from notif_typepresentation_format is a coordinated cutover between backend and mobile.

Net: notif_type = backend type identity; presentation_format = mobile presentation + new-system classification; the deeplink = destination.

Positive

  • DMs through the new pipeline get episodes again → read/unread, FAB badge, and resolvable chat deeplinks work (regression fixed).
  • The three axes become independently variable: a type can change its surface or destination without touching its identity, and a DM can route off-chat while staying a DM.
  • episode_id (read/unread) and dm_run_id (analytics) correlation both have a home.

Negative / costs

  • Dispatch gains a second external call (CreateEpisode) + a cross-service failure mode on the fire’s critical path. Mitigated by the legacy retry/fail posture.
  • Mobile work (3 changes) + a PS-6 schema change + Growth confirmation — multi-team.
  • A latency add per fire (episode create before gateway). Legacy already pays this; acceptable.

Neutral

  • The episode_id deeplink wiring is doable now with the existing consumeragent client; only dm_run_id + presentation_format fields wait on PS-6.

Resolved (was open; settled with mobile / Gleb 2026-06-22)

Section titled “Resolved (was open; settled with mobile / Gleb 2026-06-22)”
  1. Classifier vs type identity — RESOLVED via two fields. notif_type stays the verbatim backend type (scan-coach, app-open, weekly-forecast) for analytics/funnel; presentation_format is the separate mobile field that classifies (by presence) and selects the surface, with custom_ai_assistant as the conversational value. No overloading of one field — the earlier “(a)/(b)/(c)” options are moot.
  2. presentation_format enum — RESOLVED. small_toast | large_toast | fst | modal | custom_ai_assistant. push_only removed (absent field = legacy/Iterable fallback, not a value). Extension via custom_feature_X + a mobile custom handler.
  3. Coach destination — chat-route for v1 (works today). Off-chat routing is deferred.
  1. PS-6 field shape. Exact additions to CreateEpisodeRequest / the episode object for presentation_format + session_id/dm_run_id. PS-6 (PLT-681) owns.
  2. Coach response payload. POE contract §3 — pick Option A (extend the HTTP response with the ScoredCandidate/ReasonGraph fields PD-1 needs to render). Gates the real PoeEnricher. Backend ↔ Coach team.
  1. CGW (now, no PS-6 dep): add the CreateEpisode call to internal/dm/dispatch/ before the gateway POST; wire episode_id into Content.Data["deeplink"] + metadata. Restores episode-per-DM + read/unread for the new pipeline. (consumeragent client already exists.)
  2. PD-3: declare presentation_format on the registration schema (additive).
  3. Pipeline stage 5: resolve presentation_format + thread dm_run_id into the CreateEpisodeRequest (no-ops until PS-6 accepts the fields).
  4. PS-6 (PLT-681): add presentation_format + dm_run_id to CreateEpisodeRequest + the episode/BFF object + POST /episodes.
  5. Mobile: switch classification + surface selection to presentation_format (§B / Mobile changes); the FAB-unread/episode decoupling is a separate later batch.
  6. Coordinated cutover: backend starts emitting presentation_format; mobile switches its dispatcher from notif_type to presentation_format.