Spec: F93 — Notification Storyboard
The storyboard selects recipient, reason, channel, and cadence by role and lifecycle state. It orchestrates existing F77/F36/F35/F91/F85 plumbing under one restraint doctrine. Its voice may draw from docs/marketing/email.md, but product email must follow F35’s shell, deep-link, footer, and localization contract.
Status: partial — lifecycle, registry, orchestrator, read APIs, assistant controls, and web surfaces exist; backend/frontend runners, browser, live transport, and production evidence remain open
1. Problem & CEO Job
F93 serves trust the signal first and see clearly second. Push (F36), email (F35), in-app (F77), and proactive chat (F91) now decide independently: no layer distinguishes a new member from a power user or dormant owner, or stops one fact from arriving through every channel. Wrong or repeated interruptions cause muting or uninstalls; missing the right day-3 nudge loses users before first value. The storyboard makes each send earn its interruption for that person now, with day 30 never noisier than day 1.
2. User Stories & Acceptance Criteria
Roles: owner/CEO · manager · member (viewer receives nothing proactive by default). Lifecycle states: new (joined, not yet activated) · activated (hit first value) · power (recurring high engagement) · at-risk (engagement dropping) · dormant (silent past threshold).
-
As a new member, I want at most one gentle nudge toward first value, so that I’m guided, not spammed.
- Given a member in
newstate for >24h with no activation event, when the storyboard runs, then exactly onefirst_valuebeat is delivered on their least-intrusive reachable channel (chat ifDeviceToken.last_seen_atis recent, else push if a live token exists, else email) — and no second beat fires until the cooldown for that beat elapses. - Given a
newmember who just activated (first pulse/voice note), when the storyboard runs, then the pendingfirst_valuebeat is cancelled (outcome→superseded), not sent.
- Given a member in
-
As an owner/CEO, I want lifecycle-aware nudges that always name an action, so that every buzz is worth the interruption.
- Given an
activatedowner whose team has 0 other members, when the storyboard runs, then theinvite_teambeat may fire (respecting weekly cadence) with a concrete next action (“invite the two people who send you the most updates”). - Given any beat, when it is composed, then it carries a single, explicit recipient action; a beat with no action is never sent (doctrine: “if there’s no action, don’t send”).
- Given an
-
As any email recipient, I want follow-up beats to feel precise instead of spammy, so that a Lumi email reads like a useful operating nudge, not a drip campaign.
- Given a beat that routes to email, when it renders, then it uses the F35 shell: one clear reason line, one primary CTA deep-link, one fallback plain URL, and the localized footer/preference treatment appropriate to that category.
- Given an unopened or ignored storyboard email, when the same beat remains true, then at most one email follow-up may fire for that beat episode; after that Lumi must pivot channel or stay silent until the lifecycle/input state changes.
-
As a power user, I want fewer, faster signals, so that day-30 is quieter than day-1.
- Given a member in
powerstate, when a beat’s audience includespower, then its effective cadence is the storyboard’s power-lane cadence (strictly ≤ theactivatedcadence for the same beat), never more frequent.
- Given a member in
-
As a dormant member, I want one honest re-engagement, not a drip, so that I’m invited back once, not nagged.
- Given a member in
dormantstate, when thewinbackbeat fires, then at most one send occurs per dormancy episode; a secondwinbackcannot fire until the member re-activates and lapses again.
- Given a member in
-
As any recipient, I want the same fact once, on the best channel — never three times.
- Given a single underlying signal (e.g. one
stale_watchintel item), when multiple channels are eligible, then the orchestrator emits it on exactly one channel and records a shared dedup key; a second channel for the same(member, dedup_key)within the window is suppressed.
- Given a single underlying signal (e.g. one
-
As any recipient, I want quiet hours and my opt-outs honored on every channel.
- Given a member inside quiet hours, when any non-critical beat is due, then it is deferred (not dropped) to the next allowed window; critical, user-requested, or transactional sends are exempt per existing channel rules.
- Given a member who opted out of a mission/category, when a beat mapped to it is due, then it is not sent on any channel.
-
Cross-tenant (mandatory).
- Given member A in org X, when the storyboard computes/sends, then no signal, lifecycle state, or ledger row from org Y is ever read or written.
3. Scope Boundaries
- In:
- A lifecycle-state model per member (
new/activated/power/at-risk/dormant) derived from the F85ActivationEventledger, cached denormalized and refreshed by the runner. - A declarative storyboard registry (code, not DB): each beat =
{ id, trigger, audience (roles × states), channel_order, cadence/cooldown, action_key, eval_case_id }. Version-controlled so it improves by editing config + eval, not by rewiring code. Restraint, dedup, cadence, and routing are deliberately deterministic policy — not model-decided (you never want a model freely deciding whether to interrupt someone). This means the routing layer is intentionally outside F91 Bet 2’s model-absorption scope; the model-absorbing surface (beat selection or beat copy generation as tool+prompt+eval) is a named later phase (§12), not claimed here. F93 claims eval coverage (eval_case_idper beat), not model-absorption. - A notification orchestrator: for each candidate beat × member, resolve lifecycle state → applicable cadence → restraint checks (cooldown/quiet-hours/opt-out via existing
NotificationPreference+ProactiveTurn) → cross-channel dedup → route to the single best reachable channel → record the send in the existing ledgers. - Outcome→next-action mapping: ignored ⇒ back-off/pivot/escalate per beat policy; answered/acted ⇒ silence the follow-up. Extends
ProactiveTurn.outcomesemantics across channels. - The day-1 → day-30 cadence curve (frequency strictly non-increasing as a member matures, per beat).
- Lifecycle-email framing policy: F93 owns cadence/channel choice for lifecycle beats, but any beat that lands on email must still satisfy F35’s product-email shell: deep-link CTA, fallback URL, “why you got this”, localization bundle, and footer treatment. Tone can be human; structure cannot be ad hoc.
- A lifecycle-state model per member (
- Out:
- The channel transports themselves — push delivery (F36), email send/log (F35), in-app event fanout (F77), proactive chat turn generation (F91). F93 calls them; it does not reimplement them.
- The welcome-email line-by-line copy — owned by docs/marketing/email.md; F93 schedules/triggers it as lifecycle beats and may inherit tone from it, but does not own wording. Rendering, deep-link, footer, and localization rules still belong to F35.
- New notification content types (brief, risk, decision) — those are owned by their features (F12/F10/F11). F93 decides whether/when/where an existing content type reaches a person.
- Marketing-list / lifecycle emails to non-users (pure acquisition) — out; F93 is for authenticated org members only.
4. Data Model
Reuse-first. New surface is deliberately minimal.
New: MemberEngagementState (apps/notifications, extends UUIDModel, organization FK, OneToOne member) — a denormalized cache of the member’s current lifecycle bucket, computed from the F85 ActivationEvent ledger plus OrganizationMember.created_at (join time; there is no member_joined event — “new since join” reads the member row, not the ledger), so the hot orchestrator path is one indexed read, not an aggregate every tick.
state:new | activated | power | at_risk | dormant(db_index=True).state_since:DateTimeField— when the member entered the current state (drives dormancy episodes + cadence).last_event_at:DateTimeField(null=True)—occurred_atof the newestActivationEventfor this member (the recency signal the decay sweep compares against a threshold).last_event_id_watermark:UUIDField(null=True)— highestActivationEventid consumed by the promotion pass (keyset cursor).computed_at:DateTimeField— last recompute; runner refreshes on a schedule.- Transitions (state machine → update backend/DIAGRAMS.md + GLOSSARY):
new → activated(first value event) ·activated → power(≥N distinct active days in window) ·activated|power → at_risk(engagement gap) ·at_risk → dormant(silence past threshold) ·at_risk|dormant → activated(re-engagement). No→ newback-edge (new is entered only at join).powercounts only recurring events (pulse_submitted,brief_viewed,brief_item_acted,item_assigned, …) — theassistant_*funnel events are lifetime-once (dedup-keyed in F85), so they are excluded from recurrence counting orpowerwould be undercounted. - Refresh is two passes (a forward id-watermark pass alone cannot fire absence-based transitions
— a dormant member emits no new events, so a promotions-only cursor never revisits them):
- Promotion pass — keyset over new
ActivationEventrows by id since each member’slast_event_id_watermark; advancesnew→activated→powerand re-activation edges. O(new events). - Decay sweep — keyset over
MemberEngagementStatefilteredstate IN (activated, power, at_risk), ordered bystate_since, comparinglast_event_atto the beat thresholds; advances→at_risk→dormant. Bounded to the non-dormant population, ordered by the sweep index below.
- Promotion pass — keyset over new
- Scale check: one row per member — O(members). Neither pass full-scans the event table; the decay
sweep is bounded and keyset-paged, backed by
Index(organization, state, state_since). - Indexes:
state(db_index), and compositeIndex(organization, state, state_since)for the decay sweep (hot path — without it dormancy detection degrades to per-org filter+sort).
New: StoryboardSend (apps/notifications, extends UUIDModel, organization FK) — the
cross-channel dedup + outcome ledger that ties a beat to whatever channel it landed on. This is the
one join the existing per-channel logs (PushLog, EmailLog, NotificationEvent, ProactiveTurn)
lack: a single row that says “beat B reached member M via channel C for dedup-key K”.
memberFK,beat_id(CharField — the storyboard beat id, code-defined),channel(chat|push|email|in_app),dedup_key(CharField,db_index=True),lifecycle_state(snapshot at send),outcome(sent|answered|acted|ignored|deferred|suppressed|superseded),sent_at,resolved_at (null). This is a new, wider ledger thanProactiveTurn(whose enum is onlysent|answered|ignored|opted_out, chat-only) — the cross-channel cadence/cooldown it enables is net-new (PushLog/EmailLogcarry no cooldown logic today); F93 does not extend theProactiveTurnenum, it references its rows.- Links to the underlying channel artifact where one exists:
proactive_turn_id(a soft UUID reference, not a hard FK —ProactiveTurnis an ADR-036 partition target, so a cross-app FK into it is forbidden and would block partition conversion), andnotification_event(a nullable FK —NotificationEventis not a partition target). Either way the storyboard row references payload, never duplicates it. - Indexes:
(organization, member, dedup_key)for dedup lookups;(organization, member, beat_id, -sent_at)for cadence/cooldown;(beat_id, outcome)for cross-org internal eval aggregation only (deliberately org-less — must never back a tenant-facing query);(organization, sent_at, id)for partition-readiness (ADR-036 pattern, mirrorsProactiveTurn).
New: per-beat opt-out. The existing NotificationPreference proactive opt-outs are three
hard-coded booleans (proactive_pulse_enabled/brief_enabled/stale_enabled) — they cover the F91
missions but not the new beats (first_value, invite_team, winback, …). F93 adds a generic
per-beat/per-category opt-out: a storyboard_opt_outs JSONField (set of beat_id/category strings)
on NotificationPreference, checked by the orchestrator before any send. The existing booleans stay
authoritative for their three missions (no double source of truth); new beats read the JSON set.
Reused (referenced, not modified): NotificationPreference quiet-hours + channel-category +
mission flags (F93 adds the storyboard_opt_outs field above but does not change existing fields),
ProactiveTurn (chat-turn restraint ledger — a StoryboardSend on the chat channel references
one), NotificationEvent/PushLog/EmailLog (transport records; DeviceToken.last_seen_at is the
push-reachability signal — see §6/§12 channel selection), AssistantMission (existing
pulse/brief/stale missions become storyboard triggers), ActivationEvent (lifecycle source).
Storyboard registry (code, not DB): apps/notifications/storyboard.py::STORYBOARD — an ordered
list of beat definitions. Declarative on purpose: a new insight improves the product by editing a
beat’s action_key/channel_order/eval_case_id, not by touching the orchestrator. Each beat names
an eval_case_id so the harness can assert it still fires for the right audience and never for the
wrong one. (Deterministic-by-design — see §3 on why routing/restraint is not a Bet-2
model-absorbing surface; that surface is deferred, §12.)
5. API Contract
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| GET | /api/notifications/engagement-state/ | member (self) | — | { state, state_since, cadence_lane } | 401 |
| GET | /api/notifications/storyboard/preview/ | owner/admin | — | [{ beat_id, name, audience, channel_order, cadence, next_eligible_at }] | 401/403 |
| GET | /api/notifications/storyboard/sends/ | member (self) or admin (org) | keyset ?cursor= | paginated StoryboardSend rows (self, or org for admin) | 401/403 |
| PATCH | /api/notifications/preferences/ | member (self) | opt-out/quiet-hours fields | updated prefs | 401/422 |
- No new “send” endpoint — sends originate only from the runner/orchestrator, never a client request (a client cannot make Lumi notify someone else; that would bypass restraint).
- Tenant scoping: every query filtered by
organization_id=request.organization_id; the orchestrator’s runner pass iterates one org at a time and never crosses the FK. - Evidence / confidence: any beat that carries an AI-derived fact (e.g.
stale_watch,winbackreferencing a real signal) links the originatingEvidenceSourcethrough the referencedProactiveTurn/NotificationEvent— F93 adds no new AI fact, so it inherits the source of whatever content type it routes. Lifecycle state is a deterministic computation (no confidence score); beat copy is templated, not model-authored, except where it wraps an F91 proactive turn (which carries its own grounded confidence 0.85–0.90).
6. UI / UX
- Routes / components:
frontend/src/pages/settings/NotificationsPage.tsx(extend existing prefs page) — add a read-only “Your rhythm” card showing current lifecycle lane + “you’ll hear from Lumi about X, at most Y” in plain language, plus the existing opt-out/quiet-hours controls.- Owner-only Storyboard preview panel (admin HQ, F43 surface) — the beat table from
/storyboard/preview/, so an owner can see the policy that governs their team’s notifications. Read-only in P-phases; editing is a later scope change, not this spec.
- States to design: empty (no sends yet → “Lumi hasn’t needed to reach you”), loading, error, populated (rhythm card + recent sends), no-permission (non-admin sees only self).
- shadcn components:
Card,Badge(lifecycle lane),DataTable(F66, for sends/preview),Switch(opt-outs, existing),Select(quiet hours, existing). Checkfrontend/src/components/ui/— all present. - i18n namespace:
notifications(extend). Keys (en + vi together):rhythm_title,rhythm_lane_new|activated|power|at_risk|dormant,rhythm_frequency_hint,storyboard_preview_title,beat_next_eligible,sends_empty,sends_channel_chat|push|email|in_app,outcome_*. - Lifecycle beat email rules.
first_value: CTA lands in the assistant or the zero-friction first-value surface; subject/body explain what Lumi wants the member to do now, not what Lumi is.invite_team: CTA lands on invite flow or assistant invite action; body names the “why now” context (e.g. the team is still just you).winback: CTA lands on the exact recovery surface (assistant thread, brief, or pending task context) and must reference the real missed opportunity, not a generic “we miss you”.- Every email beat gets at most two email touches per lifecycle episode: the initial send and one follow-up. No infinite email ladder.
- DESIGN.md / TASTE.md: semantic tokens only (
bg-card,text-muted-foreground,bg-[var(--badge-info-surface)]for the lane badge). No stock palette. Calm, low-chrome — this screen’s job is to reassure about restraint, so it must itself feel restrained. - Assistant path (F91 doctrine): first-class. A member can ask the assistant “why did Lumi
message me?” / “stop telling me about X” / “how often will you ping me?” and the assistant
answers from
StoryboardSend+MemberEngagementStateand can perform the opt-out as a confirm-before-commit action (a preference change, ceiling-safe — no money/external/delete). The settings page is the optional direct path, never the only path. The storyboard itself is the proactive-with-restraint arm of F91 Bet 4, so it is assistant-native by construction.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Lifecycle computation: activation events move new→activated→power; gap → at_risk→dormant | backend | backend/tests/test_f93_lifecycle.py::TestLifecycleState |
| 2 | Cross-tenant isolation — orchestrator never reads/writes another org’s state or sends | backend | backend/tests/test_multitenancy.py::TestF93Storyboard |
| 3 | Cross-channel dedup — one signal, multiple eligible channels → exactly one send + shared dedup key | backend | backend/tests/test_f93_orchestrator.py::TestDedup |
| 4 | Cadence is non-increasing with maturity — power lane cooldown ≥ activated lane for same beat | backend | backend/tests/test_f93_storyboard.py::TestCadenceCurve |
| 5 | Quiet hours defer (not drop); opt-out suppresses on all channels | backend | backend/tests/test_f93_orchestrator.py::TestRestraint |
| 6 | Outcome mapping — ignored → back-off/pivot; answered/acted → follow-up silenced; superseded on activation | backend | backend/tests/test_f93_orchestrator.py::TestOutcomes |
| 7 | New-member first_value beat fires once, cancelled on activation | backend | backend/tests/test_f93_storyboard.py::TestFirstValue |
| 8 | Dormant winback fires at most once per dormancy episode | backend | backend/tests/test_f93_storyboard.py::TestWinback |
| 9 | No-action beat is never sent (doctrine guard) | backend | backend/tests/test_f93_storyboard.py::TestActionRequired |
| 10 | Every beat in STORYBOARD has an eval case; each eval asserts right-audience-fires / wrong-audience-never | backend | backend/tests/test_f93_eval.py::TestStoryboardEval |
| 11 | GET engagement-state / storyboard/preview role gating + self-scoping | backend | backend/tests/test_f93_api.py |
| 12 | Assistant performs opt-out (pause/resume nudges) as a confirm-before-commit action, reversible via undo | backend | backend/tests/test_f93_assistant_optout.py |
| 12b | Assistant answers “why did Lumi message me?” grounded in the member’s real StoryboardSend rows (tenant-scoped, no invention) | backend | backend/tests/test_f93_why_message.py |
| 13 | Rhythm card + sends list render (empty/populated/no-permission) | e2e | frontend/tests/e2e/lumi.spec.ts describe("F93 notification rhythm") |
| 14 | Email-beat shell contract — any storyboard beat routed to email uses the F35 render contract (reason line, primary CTA, fallback URL, footer treatment) and emits no mixed-locale message | backend | backend/tests/test_f93_storyboard.py, backend/tests/test_notification_i18n.py |
| 15 | Follow-up ladder restraint — an unopened/ignored invite_team or winback email can produce at most one email follow-up in the same lifecycle episode; later escalation must pivot channel or wait for state change | backend | backend/tests/test_f93_orchestrator.py::TestOutcomes |
Backend coverage must stay at ≥99% (CLAUDE.md gate ≥97%; repo runs hotter). New E2E describe block required.
8. Current Behavior
Each channel decides independently:
- F36 push —
PushLog, fired by approvals/decisions/mentions/followups; quiet-hours honored per channel; no lifecycle awareness. - F35 email — transactional + lifecycle emails now share the product-email shell (multipart plain text + HTML, CTA, fallback URL, footer treatment, locale-bundle resolution). Storyboard beats routed to email use the same renderer through
_dispatch_email. - F77 in-app —
NotificationEventledger + fanout. - F91 proactive chat —
AssistantMission(pulse/brief/stale) →ProactiveTurnrestraint ledger with cooldowns, ignore-thresholds, quiet hours, per-mission opt-outs. This is the closest existing thing to the storyboard, but scoped to three fixed missions on one channel (chat) and with no lifecycle-state input or cross-channel view. - F85 activation —
ActivationEventledger (13 event types:pulse_submitted,brief_viewed,brief_item_acted,item_assigned,confirmation_sent,second_member_invited,payment_made,extraction_flagged_inaccurate, plus the lifetime-once, dedup-keyed assistant funnelassistant_first_message/assistant_first_proposal_confirmed/assistant_voice_note_sent/assistant_brief_viewed_in_chat/assistant_invite_accepted), used for metrics, not yet consumed to drive notification decisions.
The repository now contains the lifecycle cache, declarative three-beat registry, tenant-scoped orchestrator, read APIs, assistant pause/resume and explanation tools, and web rhythm/preview surfaces. These are source-backed and registered for executable proof, but unavailable runners, browser runtime, live transport, and production delivery remain explicit Contract Graph gaps.
9. Error States
- Lifecycle recompute fails for a member → log with member/org context, skip that member this pass, retry next runner tick; never block the whole org’s pass (isolate per-member in a try/except that records to Sentry, no silent swallow).
- Channel send fails (push token dead, email bounce) → the transport already logs it (
PushLog/EmailLog); the orchestrator marksStoryboardSend.outcomeunchanged (sentattempt recorded) and, per beat policy, may fall through to the next channel inchannel_orderon the next pass — never a same-tick retry storm. - No reachable channel (member has no device token, email opted out, app not opened) → record
outcome=suppressedwith reason; do not escalate to a channel the member silenced. Better a missed nudge than a violated opt-out. - Quiet hours →
outcome=deferred, re-evaluated next window (transactional/critical exempt per existing channel rules). - User-visible copy: all beat copy uses
notificationsi18n keys (en+vi); no raw strings. A deferred/suppressed send produces no user-visible artifact (that’s the point). - Logging: every orchestrator decision (fired/deferred/suppressed/deduped) emits a structured log line with
beat_id,member_id,lifecycle_state,channel,reasonfor eval + debugging — no PII in the log payload beyond ids.
10. Diagrams & Docs Touched
- backend/DIAGRAMS.md — add
MemberEngagementStatestate machine andStoryboardSendto the ER diagram; add the orchestrator to the runner-tick sequence. - GLOSSARY.md — new terms: Lifecycle State, Storyboard Beat, Cadence Lane, Dedup Key (notification).
- DIAGRAMS.md — add F93 orchestrator to the notification/proactive flow.
- Depends on: F77 (event ledger), F36 (push), F35 (email + welcome doctrine), F91
(restraint ledger, Bet 4 proactive-with-restraint), F85 (activation ledger). ADR-036
(partition-readiness pattern) reused for
StoryboardSendindexing. A new ADR is warranted for “notification orchestration & cross-channel dedup” since it is a durable structural convention (see §12).
11. Implementation Audit (2026-07-05)
P1 + P2 + P3a/b/c shipped; F93 launched (default-on); E2E + P4 sweep not yet built. State of the implementation:
- P1 (#1210, merged):
MemberEngagementState+StoryboardSendmodels,storyboard_opt_outsopt-out field, and the two-passlifecycle.py(compute_member_state+refresh_lifecycle_states).proactive_turnis a soft UUID ref (ADR-036), not an FK. - P2 (#1211, merged):
storyboard.py(declarativeSTORYBOARD— 3 beats:first_value,invite_team,winback; each with an eval case + a mandatory action key, validated at import) andorchestrator.py(run_storyboard_for_org— opt-out → cooldown → cross-channel dedup → quiet-hours defer → reachable-channel selection → single send, all tenant-scoped). Wired intorun_mission_runneras a guarded pass (ADR-038, no new beat). Restraint delegates to the existing_is_in_quiet_hours; transports are the real F36/F35/F77/F91 paths behind a mockable_dispatch.- Implementation note — chat FK:
ProactiveTurn.missionis a required FK and there is nostoryboardmission type, so storyboard chat sends hang off one inert, disabledcustomAssistantMission per org (owner_visible_name="mission.storyboard.name"). It is never executed (runner only runsenabled=True) and is excluded from the owner-facing missions list (apps/assistant/views.mission_list) so it can’t surface as a phantom mission. Constant:orchestrator.STORYBOARD_MISSION_NAME.
- Implementation note — chat FK:
- P3a (this branch): Three read-only API endpoints (§5):
GET engagement-state/(self, any member),GET storyboard/preview/(owner/admin only; per-beatnext_eligible_atmirrors orchestrator cooldown via_COOLDOWN_BLOCKING_OUTCOMES),GET storyboard/sends/(keyset-paginated, member sees own rows; owner/admin sees org-wide). All three always filtered byorganization_id. 30 new tests intest_f93_api.py+ 3 cross-tenant cases intest_multitenancy.py::TestF93Api. No migration. - P3b (#1224/#1231, merged): frontend “your rhythm” card (lifecycle lane + frequency hint + recent
sends) + owner-only storyboard preview; plus the opt-out surface —
PATCH notification-preferences/me/acceptsstoryboard_nudges_enabled(master, flips the"all"sentinel) andstoryboard_opt_outs(fine-grained list), and the rhythm card has a “Weekly nudges” toggle. The orchestrator honours the"all"master sentinel. - P3c (this branch): the conversational opt-out — an F91 action tool
set_storyboard_nudges(require_confirm=True, reversible ceiling 2,undo_inverse), so a member can say “stop the weekly nudges” and Lumi pauses/resumes them as a confirm-before-commit action (chat parity). Propose never writes; confirm flips the"all"sentinel; undo restores the snapshot.test_f93_assistant_optout.py. - Launch (#1229):
STORYBOARD_ORCHESTRATOR_ENABLEDdefault-on (kill switch=0); Resend 2 req/s handled by the 429 retry insend_email_with_log(postmortem 2026-07-05). - P3c read (this branch): the assistant “why did Lumi message me?” explanation — a read-only
toolbelt tool
explain-recent-notifications(require_confirm=False, no action_type) that returns the member’s own recentStoryboardSendrows (tenant-scoped) with a plain per-beat reason, so the model answers grounded (F86 — facts from real rows, never invented).test_f93_why_message.py. - P4 ignored-sweep (this branch):
sweep_ignored_sendsmarks stale unresolvedsentrowsignoredafter a 72h response window (bulk, tenant-scoped, idempotent), wired into the mission tick behind the launch gate. Keeps cooldown (ignored == sent) so Lumi never escalates on non-responders. - Gap list for the remainder: the E2E
describeblock. ✅ Proactive per-tick email pacing (#1236); P4 ignored-sweep (this branch) shipped. - Email quality gap closed (2026-07-08): storyboard emails now render through the shared F35 shell/localization contract instead of ad-hoc plain-text sends.
_dispatch_emailresolves one coherent locale bundle, renders multipart text + HTML, and includes the shared CTA/footer treatment; seetests/assistant/test_f93_orchestrator.py::TestChannelSelection::test_no_token_falls_through_to_email. Convert F91 missions to flow through storyboard dedup: deferred.
Codebase constraints that shape the design:
- The restraint plumbing already exists and is production-grade (
ProactiveTurncooldown index,NotificationPreferencequiet-hours/opt-outs, per-channel logs). F93 must not duplicate it — the orchestrator reads these and references their rows, adding only the cross-channel join (StoryboardSend) and the lifecycle input (MemberEngagementState). AssistantMission/ProactiveTurnmodel the chat channel’s restraint per-mission; F93 generalizes the policy (which mission/content, to whom, when) into the declarativeSTORYBOARDwhile leaving the chat transport (F91’srun_agent_loop/proactive turn) untouched.- F85
ActivationEventis the lifecycle presence signal source (new/activated/powerand re-activation) — derived by a keyset id-watermark promotion pass. Join time comes fromOrganizationMember.created_at(nomember_joinedevent exists). Absence transitions (at_risk/dormant) cannot come from a forward event cursor — they need the separate keyset decay sweep overMemberEngagementState(§4), because a silent member emits no new events. - The welcome-email copy is owned by marketing doctrine; F93 triggers it as
new-state beats but must not fork the copy.
12. Open Questions / Decisions
ADR needed (before implementation): orchestrator as new scheduler vs. extend F91 runner.Resolved — ADR-038 (Accepted 2026-07-05): extend the existingrun_mission_runner; one home for restraint; no second beat. P1 is unblocked.- Lifecycle thresholds (power = ≥N active days over W; at_risk gap; dormant threshold) — start
with conservative defaults in
storyboard.pyconfig, tune against realActivationEventdata post-launch. Values are config, not code, so tuning is not a rewrite. - Channel-reachability signal for “least-intrusive reachable channel” — there is no
app_openActivationEventtoday. The real candidate isDeviceToken.last_seen_at(push-scoped). Decide in P1: (a) treat a recentDeviceToken.last_seen_atas the chat-reachability proxy, or (b) add a lightweightapp_openevent/last_seen_aton the member. Prefer (a) — no new heartbeat — unless P1 findslast_seen_attoo push-biased to represent chat presence. - Push channel depends on FCM being configured (operational). The
pushchannel only delivers onceFCM_SERVICE_ACCOUNT_JSONis set in the deployed envs — until thensend_fcm_pushsilently no-ops and the orchestrator’s push sends reach nothing (chat/email/in_app still work). Tracked as the operational task in F36 §12. - Beat authorship for smarter models (F91 Bet 2): beats are declarative config today. Open question whether beat selection (which beat best fits a member now) should itself become a model-scored decision with an eval harness in a later phase — deferred, noted so we don’t hand-roll a rule tree that can’t absorb a smarter model.
13. Bug / Change Log
2026-07-05 — P3a/b/c shipped + F93 launched
Type: Feature
Summary:
- P3a read-only API (#1217); P3b rhythm card + opt-out endpoint/toggle (#1224/#1231); P3c
conversational opt-out action tool
set_storyboard_nudges(confirm-before-commit, undoable). Launch (#1229): orchestrator default-on with a=0kill switch.
Reason:
- Deliver the user-facing surfaces (see rhythm, control cadence) and chat parity for the opt-out (Assistant-First doctrine), then launch. Restraint honoured on every send; the 429 incident (LUMI-BE-1C) fixed with a retry before launch.
Tests:
test_f93_api.py,test_f93_optout.py,test_f93_assistant_optout.py,RhythmCard.test.tsx, cross-tenant classes intest_multitenancy.py. Backend ≥97%; fullpnpm testgreen.
Notes:
- Still open at that point: assistant “why did Lumi message me?” grounded explanation, E2E block, P4 ignored-sweep + proactive email pacing.
2026-07-08 — Email-beat policy tightened: F93 owns cadence, F35 owns shell
Type: Documentation Change
Summary:
- Clarified a stale conflict:
docs/marketing/email.mdmay still own welcome-sequence voice, but storyboard-triggered product emails cannot inherit its old plain-text/no-footer transport assumptions. - Added acceptance criteria and test-plan rows requiring lifecycle beats routed to email to use the F35 shell, deep-link CTA, fallback URL, localized footer treatment, and a max-two-email follow-up ladder per lifecycle episode.
Reason:
- The existing F93 text was too loose and could be read as permission for ad hoc, low-structure emails exactly where the product needs sharp activation and winback messaging.
Impact:
- Future F93 implementation work has a concrete contract for email follow-ups: better structure, better restraint, no endless drip.
Tests:
- Planned in §7 rows 14–15; no code changes in this spec pass.
2026-07-05 — Spec created
Type: Feature
Summary:
- Created F93 notification-storyboard spec: role × lifecycle-state orchestration across email/push/ chat/in-app, cross-channel dedup, outcome→next-action mapping, day-1→day-30 non-increasing cadence, all inheriting the email.md restraint doctrine and F91 Bet 4.
Reason:
- Notification channels fired in isolation with no lifecycle awareness and no cross-channel deduplication — the retention-killing failure mode (redundant/ill-timed buzz → mute/uninstall).
Impact:
- New thin orchestration layer over existing F77/F36/F35/F91/F85 infrastructure; two small new models
(
MemberEngagementState,StoryboardSend) + a declarativeSTORYBOARDregistry. No changes to channel transports.
Tests:
- Planned per §7 (lifecycle, dedup, cadence curve, restraint, outcomes, eval, API, assistant, e2e).
Notes:
- P1 is blocked on an ADR (§12) deciding runner integration. Do not reimplement restraint plumbing — reference the existing ledgers. Welcome-email copy stays owned by marketing doctrine.
2026-07-05 — P1 + P2 backend shipped
Type: Feature
Summary:
- P1: lifecycle models + two-pass
refresh_lifecycle_states(#1210). - P2:
STORYBOARDregistry (3 beats) +orchestrator.run_storyboard_for_org(opt-out → cooldown → cross-channel dedup → quiet-hours defer → reachable-channel select → single send), wired into the F91 mission runner (ADR-038, no new beat). Restraint reuses_is_in_quiet_hours; transports reuse F36/F35/F77/F91.
Reason:
- Deliver the lifecycle spine (P1) then the decision engine that dedupes across channels and honours restraint per role × lifecycle state (P2).
Impact:
- New backend-only orchestration; no user-facing surface yet (P3). F91 missions unchanged.
Tests:
test_f93_lifecycle(P1),test_f93_orchestrator/test_f93_storyboard/test_f93_eval(P2), cross-tenant classes intest_multitenancy. Full suite 6501 passed, 97.14% coverage; partition + rolling-deploy guards green.
Notes:
- Storyboard chat sends use one inert disabled
custommission per org to satisfy theProactiveTurn.missionFK; it is excluded from the owner-facing missions list (regression-tested). sweep_ignored_sendsis a P4 stub; ignored never shortens cooldown (never escalate).
2026-07-05 — P3a read-only storyboard API shipped
Type: Feature
Summary:
- Three read-only endpoints:
GET engagement-state/(self),GET storyboard/preview/(owner/admin),GET storyboard/sends/(keyset-paginated; member=own rows, admin=org-wide). _next_eligible_atmirrors orchestrator cooldown logic via_COOLDOWN_BLOCKING_OUTCOMES._CADENCE_LANEmapping (new/activated/power/at_risk/dormant → lane label) is a pure config dict.- No migration; no writes; no new models.
Reason:
- Expose the storyboard state (lifecycle bucket, beat policy, send ledger) so the frontend “your rhythm” card and owner preview panel can be built in P3 (next).
Impact:
- Backend-only surface; no frontend or native app changes yet.
Tests:
- 30 new tests in
backend/tests/test_f93_api.py+ 3 cross-tenant tests intest_multitenancy.py::TestF93Api. Full suite 6528 passed, 97.15% coverage.
The owner/admin GET /api/notifications/storyboard/preview/ surface is explicitly registered to
storyboard_preview, with owner response-shape and member-deny proofs; this does not establish
browser, native, live-transport, or production evidence.
The member/admin GET /api/notifications/storyboard/sends/ surface is explicitly registered to
storyboard_sends, with member self-scope and tenant-isolation proof; this does not establish
browser, native, live-transport, or production evidence.