partial — lifecycle, registry, orchestrator, read APIs, assistant controls, and web surfaces exist; backend/frontend runners, browser, live transport, and production evidence remain open

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 new state for >24h with no activation event, when the storyboard runs, then exactly one first_value beat is delivered on their least-intrusive reachable channel (chat if DeviceToken.last_seen_at is recent, else push if a live token exists, else email) — and no second beat fires until the cooldown for that beat elapses.
    • Given a new member who just activated (first pulse/voice note), when the storyboard runs, then the pending first_value beat is cancelled (outcome→superseded), not sent.
  • As an owner/CEO, I want lifecycle-aware nudges that always name an action, so that every buzz is worth the interruption.

    • Given an activated owner whose team has 0 other members, when the storyboard runs, then the invite_team beat 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”).
  • 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 power state, when a beat’s audience includes power, then its effective cadence is the storyboard’s power-lane cadence (strictly ≤ the activated cadence for the same beat), never more frequent.
  • 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 dormant state, when the winback beat fires, then at most one send occurs per dormancy episode; a second winback cannot fire until the member re-activates and lapses again.
  • 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_watch intel 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.
  • 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 F85 ActivationEvent ledger, 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_id per 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.outcome semantics 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.
  • 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_at of the newest ActivationEvent for this member (the recency signal the decay sweep compares against a threshold).
  • last_event_id_watermark: UUIDField(null=True) — highest ActivationEvent id 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 → new back-edge (new is entered only at join). power counts only recurring events (pulse_submitted, brief_viewed, brief_item_acted, item_assigned, …) — the assistant_* funnel events are lifetime-once (dedup-keyed in F85), so they are excluded from recurrence counting or power would 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):
    1. Promotion pass — keyset over new ActivationEvent rows by id since each member’s last_event_id_watermark; advances new→activated→power and re-activation edges. O(new events).
    2. Decay sweep — keyset over MemberEngagementState filtered state IN (activated, power, at_risk), ordered by state_since, comparing last_event_at to the beat thresholds; advances →at_risk→dormant. Bounded to the non-dormant population, ordered by the sweep index below.
  • 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 composite Index(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”.

  • member FK, 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 than ProactiveTurn (whose enum is only sent|answered|ignored|opted_out, chat-only) — the cross-channel cadence/cooldown it enables is net-new (PushLog/EmailLog carry no cooldown logic today); F93 does not extend the ProactiveTurn enum, it references its rows.
  • Links to the underlying channel artifact where one exists: proactive_turn_id (a soft UUID reference, not a hard FK — ProactiveTurn is an ADR-036 partition target, so a cross-app FK into it is forbidden and would block partition conversion), and notification_event (a nullable FK — NotificationEvent is 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, mirrors ProactiveTurn).

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

MethodPathAuth/RoleRequestResponseErrors
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 fieldsupdated prefs401/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, winback referencing a real signal) links the originating EvidenceSource through the referenced ProactiveTurn/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). Check frontend/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 + MemberEngagementState and 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

#CaseTypeWhere
1Lifecycle computation: activation events move new→activated→power; gap → at_risk→dormantbackendbackend/tests/test_f93_lifecycle.py::TestLifecycleState
2Cross-tenant isolation — orchestrator never reads/writes another org’s state or sendsbackendbackend/tests/test_multitenancy.py::TestF93Storyboard
3Cross-channel dedup — one signal, multiple eligible channels → exactly one send + shared dedup keybackendbackend/tests/test_f93_orchestrator.py::TestDedup
4Cadence is non-increasing with maturity — power lane cooldown ≥ activated lane for same beatbackendbackend/tests/test_f93_storyboard.py::TestCadenceCurve
5Quiet hours defer (not drop); opt-out suppresses on all channelsbackendbackend/tests/test_f93_orchestrator.py::TestRestraint
6Outcome mapping — ignored → back-off/pivot; answered/acted → follow-up silenced; superseded on activationbackendbackend/tests/test_f93_orchestrator.py::TestOutcomes
7New-member first_value beat fires once, cancelled on activationbackendbackend/tests/test_f93_storyboard.py::TestFirstValue
8Dormant winback fires at most once per dormancy episodebackendbackend/tests/test_f93_storyboard.py::TestWinback
9No-action beat is never sent (doctrine guard)backendbackend/tests/test_f93_storyboard.py::TestActionRequired
10Every beat in STORYBOARD has an eval case; each eval asserts right-audience-fires / wrong-audience-neverbackendbackend/tests/test_f93_eval.py::TestStoryboardEval
11GET engagement-state / storyboard/preview role gating + self-scopingbackendbackend/tests/test_f93_api.py
12Assistant performs opt-out (pause/resume nudges) as a confirm-before-commit action, reversible via undobackendbackend/tests/test_f93_assistant_optout.py
12bAssistant answers “why did Lumi message me?” grounded in the member’s real StoryboardSend rows (tenant-scoped, no invention)backendbackend/tests/test_f93_why_message.py
13Rhythm card + sends list render (empty/populated/no-permission)e2efrontend/tests/e2e/lumi.spec.ts describe("F93 notification rhythm")
14Email-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 messagebackendbackend/tests/test_f93_storyboard.py, backend/tests/test_notification_i18n.py
15Follow-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 changebackendbackend/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 — NotificationEvent ledger + fanout.
  • F91 proactive chat — AssistantMission (pulse/brief/stale) → ProactiveTurn restraint 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 — ActivationEvent ledger (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 funnel assistant_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 marks StoryboardSend.outcome unchanged (sent attempt recorded) and, per beat policy, may fall through to the next channel in channel_order on 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=suppressed with 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 notifications i18n 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, reason for eval + debugging — no PII in the log payload beyond ids.

10. Diagrams & Docs Touched

  • backend/DIAGRAMS.md — add MemberEngagementState state machine and StoryboardSend to 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 StoryboardSend indexing. 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 + StoryboardSend models, storyboard_opt_outs opt-out field, and the two-pass lifecycle.py (compute_member_state + refresh_lifecycle_states). proactive_turn is a soft UUID ref (ADR-036), not an FK.
  • P2 (#1211, merged): storyboard.py (declarative STORYBOARD — 3 beats: first_value, invite_team, winback; each with an eval case + a mandatory action key, validated at import) and orchestrator.py (run_storyboard_for_org — opt-out → cooldown → cross-channel dedup → quiet-hours defer → reachable-channel selection → single send, all tenant-scoped). Wired into run_mission_runner as 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.mission is a required FK and there is no storyboard mission type, so storyboard chat sends hang off one inert, disabled custom AssistantMission per org (owner_visible_name="mission.storyboard.name"). It is never executed (runner only runs enabled=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.
  • P3a (this branch): Three read-only API endpoints (§5): GET engagement-state/ (self, any member), GET storyboard/preview/ (owner/admin only; per-beat next_eligible_at mirrors orchestrator cooldown via _COOLDOWN_BLOCKING_OUTCOMES), GET storyboard/sends/ (keyset-paginated, member sees own rows; owner/admin sees org-wide). All three always filtered by organization_id. 30 new tests in test_f93_api.py + 3 cross-tenant cases in test_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/ accepts storyboard_nudges_enabled (master, flips the "all" sentinel) and storyboard_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_ENABLED default-on (kill switch =0); Resend 2 req/s handled by the 429 retry in send_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 recent StoryboardSend rows (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_sends marks stale unresolved sent rows ignored after 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 describe block. ✅ 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_email resolves one coherent locale bundle, renders multipart text + HTML, and includes the shared CTA/footer treatment; see tests/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 (ProactiveTurn cooldown index, NotificationPreference quiet-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/ProactiveTurn model the chat channel’s restraint per-mission; F93 generalizes the policy (which mission/content, to whom, when) into the declarative STORYBOARD while leaving the chat transport (F91’s run_agent_loop/proactive turn) untouched.
  • F85 ActivationEvent is the lifecycle presence signal source (new/activated/power and re-activation) — derived by a keyset id-watermark promotion pass. Join time comes from OrganizationMember.created_at (no member_joined event exists). Absence transitions (at_risk/dormant) cannot come from a forward event cursor — they need the separate keyset decay sweep over MemberEngagementState (§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 existing run_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.py config, tune against real ActivationEvent data 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_open ActivationEvent today. The real candidate is DeviceToken.last_seen_at (push-scoped). Decide in P1: (a) treat a recent DeviceToken.last_seen_at as the chat-reachability proxy, or (b) add a lightweight app_open event/last_seen_at on the member. Prefer (a) — no new heartbeat — unless P1 finds last_seen_at too push-biased to represent chat presence.
  • Push channel depends on FCM being configured (operational). The push channel only delivers once FCM_SERVICE_ACCOUNT_JSON is set in the deployed envs — until then send_fcm_push silently 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 =0 kill 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 in test_multitenancy.py. Backend ≥97%; full pnpm test green.

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.md may 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 declarative STORYBOARD registry. 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: STORYBOARD registry (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 in test_multitenancy. Full suite 6501 passed, 97.14% coverage; partition + rolling-deploy guards green.

Notes:

  • Storyboard chat sends use one inert disabled custom mission per org to satisfy the ProactiveTurn.mission FK; it is excluded from the owner-facing missions list (regression-tested).
  • sweep_ignored_sends is 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_at mirrors orchestrator cooldown logic via _COOLDOWN_BLOCKING_OUTCOMES.
  • _CADENCE_LANE mapping (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 in test_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.