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)
Context
Section titled “Context”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):
caClient.CreateEpisode(userID, episodeReq)→ returnsepisode_id(consumer-agent’s episode builder — a separate service from notification-service).POST /v1/gateway/notifywith the episode_id wired into the push:metadata["episode_id"] = episodeIDContent.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-emptysubActionValue; 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.handleclassifies a notification as a DM iff its deeplink containssubAction=episode— so “is this a DM” is inferred from the destination.AssistantDomainDeepLinkRequestroutes the tap on the deeplink scheme (ai_assistant?subAction=episode→ chat; thesubActionValueepisode_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: 1→selected_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.
Decision
Section titled “Decision”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_idis also a unique per-DM id (one episode per fire) and is what read/unread keys on (§A/§B). Butepisode_idexists only for inbox DMs (§A2), and only after the episode-builder call succeeds, whereassession_idexists for every fire from the moment it’s minted. Sosession_idis the universal id;episode_idis 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_idADR-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_iddrives 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 noepisode_iddelivers + displays as an ordinary banner — the gateway has no episode_id gate, and iOSDirectMessageHandlersimply returnsfalse(not classified as a DM) and shows it normally. Identity/analytics usesession_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.
| Concern | Field | Meaning | Owner | Status |
|---|---|---|---|---|
| Type identity | notif_type (= PD-3 type_id, verbatim) | what this message is (scan-coach, app-open, weekly-forecast) — drives audit, funnel, analytics | backend | live, unchanged |
| Presentation + classification | presentation_format | how 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 contract | proposed (in-progress on mobile) |
| Destination | deeplink (push) + cta_action (episode card) | where a tap goes | backend (URL string) | live mechanism |
Decisions:
notif_typestays 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_formatis 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 byFetchNotificationHandlervs. falling back to legacy Iterable — see below);custom_ai_assistantis 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 iOSNotifTypeenum (custom_ai_assistant/small_toast/large_toast/modal) shows the presentation vocabulary currently rides thenotif_typewire field; the plan moves that vocabulary topresentation_formatand returnsnotif_typeto the granular backend type. So there is a coordinated cutover: backend starts emittingpresentation_format; mobile switches its dispatcher from readingnotif_typeto readingpresentation_format. - Absent
presentation_format= legacy/Iterable handling, NOT a notification-service banner. Verified in iOSAppDelegate+RemoteNotifications.swift: a push not claimed byFetchNotificationHandlerfalls through to Iterable (Messaging.shared...) on tap. So a message with nopresentation_formatis 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 nopush_onlyvalue — 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 acustom_feature_Xvalue 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 intoContent.Data["deeplink"](the tap target) and the episode’scta_action(the feed-card button). No new transport; legacy already does this. Constraint: the URL scheme/path must be one iOS’sDeepLinkManageralready 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_idis already the base SK, but a user-PK GSI lets you query a user’s history across all runs without knowingdm_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 asession_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(andevent_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.
D. Where the new fields live
Section titled “D. Where the new fields live”presentation_formatmust be persisted onto the episode/BFF object (PS-6 owns the schema), not only the transientNotifyRequest— 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_idpersisted onto the episode (analytics correlation), so engagement events can join back to the exact fire. (dm_run_idtoo, 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/ReasonGraphfields PD-1 needs to render). Until that’s agreed, the enricher is the [STUB] pass-through already inearningscoach.PoeEnricher.
Mobile changes (ios-fetch-rewards)
Section titled “Mobile changes (ios-fetch-rewards)”The presentation/classification work (mobile-led, in-progress):
-
Classify + select surface on
presentation_format. TodayDirectMessageHandler.handleclassifies by thesubAction=episodedeeplink heuristic, and the dispatcher reads the presentation vocabulary offnotif_type. The plan: readpresentation_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_typereverts to the granular backend type for analytics only. -
(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_type → presentation_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.
Consequences
Section titled “Consequences”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) anddm_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_iddeeplink wiring is doable now with the existingconsumeragentclient; onlydm_run_id+presentation_formatfields 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)”- Classifier vs type identity — RESOLVED via two fields.
notif_typestays the verbatim backend type (scan-coach,app-open,weekly-forecast) for analytics/funnel;presentation_formatis the separate mobile field that classifies (by presence) and selects the surface, withcustom_ai_assistantas the conversational value. No overloading of one field — the earlier “(a)/(b)/(c)” options are moot. presentation_formatenum — RESOLVED.small_toast | large_toast | fst | modal | custom_ai_assistant.push_onlyremoved (absent field = legacy/Iterable fallback, not a value). Extension viacustom_feature_X+ a mobile custom handler.- Coach destination — chat-route for v1 (works today). Off-chat routing is deferred.
Still open
Section titled “Still open”- PS-6 field shape. Exact additions to
CreateEpisodeRequest/ the episode object forpresentation_format+session_id/dm_run_id. PS-6 (PLT-681) owns. - Coach response payload. POE contract §3 — pick Option A (extend the HTTP response with
the
ScoredCandidate/ReasonGraphfields PD-1 needs to render). Gates the realPoeEnricher. Backend ↔ Coach team.
Implementation sketch (when accepted)
Section titled “Implementation sketch (when accepted)”- CGW (now, no PS-6 dep): add the
CreateEpisodecall tointernal/dm/dispatch/before the gateway POST; wireepisode_idintoContent.Data["deeplink"]+metadata. Restores episode-per-DM + read/unread for the new pipeline. (consumeragentclient already exists.) - PD-3: declare
presentation_formaton the registration schema (additive). - Pipeline stage 5: resolve
presentation_format+ threaddm_run_idinto theCreateEpisodeRequest(no-ops until PS-6 accepts the fields). - PS-6 (PLT-681): add
presentation_format+dm_run_idtoCreateEpisodeRequest+ the episode/BFF object +POST /episodes. - Mobile: switch classification + surface selection to
presentation_format(§B / Mobile changes); the FAB-unread/episode decoupling is a separate later batch. - Coordinated cutover: backend starts emitting
presentation_format; mobile switches its dispatcher fromnotif_typetopresentation_format.