shipped

Spec: F12 — CEO Morning Brief

Status: shipped · Audited: 2026-07-10

1. Problem & CEO Job

See the day before it starts. A CEO opens Zalo, scrolls work groups, cross-checks Excel, and relies on memory; none reads everything or prioritizes it. The Brief replaces that manual synthesis with one CEO-executable screen: ranked changes since yesterday, decisions for today, and risks, generated at a configured time and refreshed on demand.

What makes a Brief worth reading. Completeness creates noise. Surface the correctly ranked three to five items needing attention and link directly to action: “decide this,” “look at this risk,” “someone missed their commitment.” The Brief is a filter, not a log.

The Brief also delivers the F70 wedge: first run turns pasted Zalo threads, the Excel tracker, and a screenshot into a ranked Boss Brief in under 10 minutes, with zero setup. The daily Brief is the same surface, fed by ongoing pulses rather than one paste.

2. User Stories & Acceptance Criteria

  • JTBD (CEO): “When I wake up at 6am, I need a synthesized view of what my team sent yesterday — so I don’t have to spend 30 minutes scrolling Zalo groups and opening the Excel tracker before I can think clearly. Today there is no software doing this: the only ‘synthesis’ is the owner’s own memory stitching together chat messages and a spreadsheet. Lumi generates the Brief from pulse data without me asking.”
    • Given POST /api/briefs/generate/, then a Brief is created (or the existing today-Brief is replaced) with BriefItems from the last 24h of pulses — risks, decisions, commitments, flagged status updates. Same-day re-calls delete the previous BriefItems and recreate them from live state; repeat calls are bounded by the brief_generate throttle (10/hour, not an idempotency gate).
    • Given generation succeeds on web, then the active Brief query finishes refetching before the action resolves and the CEO sees explicit success feedback with the server generation time; the route does not navigate away.
    • Given no pulse submissions yesterday, then the Brief is generated with an empty section and a context note (“Hôm qua không có pulse nào — nhắc nhở team báo cáo”).
  • JTBD (CEO): “When I read the Brief, I need each section to be ranked so the most urgent thing is always first — so I don’t accidentally read about a medium risk before seeing a critical one that needs my decision today. A Zalo feed shows messages in whatever order they arrived, and an Excel row is as loud as any other; nothing tells the owner what matters first. Lumi ranks within each section: critical before high, overdue before upcoming.”
    • Given §1 Pulse Summary: team participation visible (who submitted, who missed).
    • Given §2 Risks: open high/critical only, ordered by severity.
    • Given §3 Decisions: open decisions ordered by decision_needed_by (overdue first, critical urgency badge).
    • Given §4 Commitments: overdue/at-risk commitments (not all — this is the CEO’s queue, not a task list).
    • Given §5 Highlights: positive signals (targets hit, milestones reached) — balance to the risk-heavy sections.
  • JTBD (CEO): “When I see a risk in the Brief, I need to assign mitigation in one tap — so I can delegate without leaving the Brief. Today delegating means switching back to Zalo, typing the message to the right person, and hoping it isn’t lost in the scroll — with nothing linking the ask back to the risk. Lumi links brief → risk → assign task in one chain.”
    • Given a risk BriefItem, then tapping “Giao việc” opens a pre-filled task creation panel (F25) with the risk context, evidence, and assignee selector.
    • Given a decision BriefItem, then tapping “Quyết định” opens the decide panel inline (F11 decide action).
  • JTBD (CEO): “When I approve or reject an item in the Brief, I need the Brief to update immediately — so I don’t re-read stale items. With Zalo and Excel, an approval lives only in the owner’s head until they remember to edit the spreadsheet; the ‘morning view’ is whatever they last skimmed. Lumi dismisses the BriefItem immediately upon action, and re-generating the Brief re-pulls live state.”
    • Given resolve/dismiss a risk → the linked BriefItem is immediately dismissed (dismissed_at stamped).
    • Given decide/cancel a decision → the linked BriefItem is immediately dismissed.
    • Given the CEO taps “Cập nhật Brief” → generate runs again; new/changed items appear; dismissed items stay dismissed.

3. Scope Boundaries

  • In: Brief generation (synthesize last-24h pulse data → typed, ranked BriefItems), sections §1–§5, inline actions (decide, assign risk task), dismiss, re-generate, LLM synthesis of summary_text + per-item ceo_action_suggestion in the CEO’s language (§4a, grounded strictly on the ranked items; Gemini/DeepSeek via F37 contract).
  • Out: email delivery, mobile push (revisit post-MVP), custom section configuration, multi-day trend synthesis (one brief per day), LLM-driven selection/ranking of items (the query layer still chooses and ranks items — the LLM only narrates and suggests; this keeps the brief grounded and fast).

§4a — LLM synthesis layer (the chief-of-staff voice)

The selection + ranking layer is unchanged — generate_morning_brief still queries the org graph and produces typed, ranked BriefItems with evidence. That grounded list is the moat. On top of it, a single synthesis pass turns the list into a brief a CEO wants to read, in their language:

  1. After items are built (before bulk_create’s summary), assemble the grounded DATA block via prompts.build_brief_prompt(items) — item id, type, title, key facts (owner, due, amount, severity), no invented content.
  2. Call the provider through language_check.check_and_retry(...) with build_system_prompt(member, "brief") (member = the brief’s CEO/owner) requesting strict JSON {"summary": ..., "suggestions": {"<item_id>": ...}}.
  3. Write brief.summary_text and each BriefItem.ceo_action_suggestion from the response. All text is in the CEO’s profile language (F37).
  • Mock mode (default): no LLM call — the existing deterministic suggestions are produced via member-language templates (replacing today’s hardcoded Vietnamese strings) so the contract holds in dev/CI. vi templates match current copy exactly.
  • Failure isolation: any LLMProviderError or malformed JSON falls back to the deterministic template strings — the brief MUST always generate. The miss is logged.
  • Grounding: the LLM may only narrate/suggest from the provided items; it never adds, drops, or invents items. Item selection, FKs, evidence, and confidence are untouched.
  • Latency: one batched call for the whole brief (not per-item) to hold p95 < 5s.

§4b — Scale: large organizations (hundreds–thousands of employees)

A 10-person org and a 500-person org both get the same ~5–12 item brief — that is the product invariant: the brief is a filter, not a log (§1). What changes at scale is not the output size but how we choose those items and how cheaply we generate them. The architecture is already scale-safe in the one dimension that matters most: the LLM only ever synthesizes a pre-bounded, pre-ranked item set (≤ _BRIEF_SOFT_CAP), never raw org data. A 500-pulse day still produces one bounded prompt. The remaining work is selection quality, aggregation, tiering, and bounded retrieval.

  1. Selection by priority score, not section order + first-N. Today items are taken in section order, capped by remaining_slots() — at 10 people that’s fine; at 500 it silently drops a critical risk in Ops because Sales decisions filled the cap first. Replace the first-come cap with a single priority score per candidate (severity × business_impact × staleness × money_amount × customer_blast_radius), pooled across all categories, then take the global top N. The brief shows the most important items across the whole company, regardless of category.

  2. Aggregate, don’t enumerate. When a category exceeds a threshold (e.g. > 3 overdue commitments in one team), emit one roll-up BriefItem with a count (“Ops — 11 commitments overdue, oldest 9 days: ”) plus a drill-in link, instead of 11 lines. The CEO sees the pattern; the detail is one tap away. This keeps the brief readable and the LLM prompt small no matter the headcount.

  3. Tiered briefs (the real answer for 500 people). One flat company brief cannot represent 500 people’s work without becoming noise. The model is hierarchical, built on F25 brief assignment:

    • Team/manager briefs — each manager gets a brief scoped to their team’s slice (same generator, team_id filter).
    • Company brief (CEO) — a cross-team roll-up: the globally top-scored items + per-team one-line summaries (“Sales: 2 stalled deals >50M; Ops: 11 overdue; Finance: clean”). The CEO sees company-level signal and can drill into any team’s brief. This makes the brief’s cognitive load independent of headcount.
  4. Bounded, indexed retrieval (scale-check rails). Every brief query stays LIMIT-bounded (never count() the full org, never load all pulses) and must be backed by composite indexes on the hot filters — (organization, status, due_date) on Task/Commitment, (organization, status, severity) on Risk. Selection scoring runs over the bounded candidate pool (top-K per category fetched with LIMIT), not the entire table.

  5. Async fan-out, bounded concurrency. Generating N team briefs is N synthesis calls — run them in the existing Celery path (apps/briefs/tasks.py) with bounded concurrency and provider rate-limit handling, never inline in a request. Cache the generated brief so repeated opens don’t re-call the LLM.

Status: invariant #1 (bounded LLM input) holds today. Items 1–5 are the scale backlog — tracked as a follow-up; priority scoring + aggregate roll-ups are the highest-value first step and are implementable without the LLM. Tiering is gated on F25.

§4c — Cross-Type Deterministic Ranking Contract

Item 1 (§4b) — replace first-come cap with a single priority score — is implemented on the Issue #1652 branch and pending merge as of 2026-07-10. The ranking is fully deterministic: the model never chooses or re-orders facts. All candidates are scored by a pure function of known database fields, sorted, and taken as the global top N.

Score formula. Each candidate receives an integer score:

score = TYPE_BASE + SEVERITY_BONUS + URGENCY_BONUS + IMPACT_BONUS
FactorRangeSource facts
Type base10000–20000item_type — decision=20000, approval=19000, risk=18000, cash=17000, customer=16000, commitment=15000, overdue=14000, task=12000, opportunity=11000, deal=10000, highlight=9000, people=8000, summary=5000
Severity0–8000severity — critical=8000, high=5000, medium=2000; Task priority — critical=3000, high=1500
Decision urgency0–5000decision_needed_by <= brief_date → +5000
Staleness0–3000created_at >3 days old → +min(age_days×200, 3000)
Overdue age0–3000due_date < brief_date → +min(overdue_days×200, 3000)
Customer exposure0–3000related_customer_name or customer keywords present
Cash exposure0–2000money_amount or cash keywords present

Precedence guarantees (hardened by golden fixtures):

  1. Critical risk vs low-priority decision: a critical risk (~31000) always outscores a non-urgent decision (20000) — the CEO sees active fires before routine asks.
  2. Due-today decision vs non-urgent risk: an overdue decision (≥25000) outscores a medium risk (≤22000) — a decision the CEO must make today takes priority before a medium operational flag they only need to be aware of.
  3. Cash/customer exposure bonus: items with cash (+2000) or customer (+3000) exposure rank strictly above their unexposed peers of the same type/severity — revenue and relationship risks surface before abstract ones.
  4. Equal-score stability: ties are resolved by created_at ISO datetime (ascending, so longer-ignored items win) — no non-determinism, no ordering flip on re-generate.

Bounded candidate gathering. Each candidate source (decisions, risks, commitments, overdue tasks, customer/cash tasks, opportunities, deals) fetches at most _SECTION_LIMIT candidates (_BRIEF_CAP × 3 = 36). This bounds every source well below a full table scan even at 500-person scale: a 500-task overdue section gathers 36 candidates, not 500. The cap is uniform across all sources — no source pre-empts another by flooding the candidate pool.

One global cap. The brief always contains at most _BRIEF_CAP = 12 items. rank_and_cap() selects the top 12 scored candidates from the full pooled set — no per-section quotas, no first-come/first-served advantage. A high-score item from the last source (e.g. a stuck deal) can and does outrank a low-score item from an earlier source (e.g. a routine decision).

No model chooses facts — ranking is deterministic. The LLM (when not in mock mode) only narrates summary_text and writes ceo_action_suggestion — it never selects, drops, or re-ranks items. Cross-type ranking is a pure rule function: given the same database state at the same moment, generate_morning_brief always produces the same candidate pool, the same scores, the same ranks, and the same selection. Every BriefItem carries rank so the serialization order is verifiably the ordering the ranking engine produced.

generation_meta — inspectable overflow ledger. Every Brief carries a generation_meta JSON field (populated on each generate call):

{
  "eligible_count": 42,
  "overflow_count": 30,
  "candidate_sources": {
    "decision": 5,
    "risk": 12,
    "commitment": 8,
    "overdue": 10,
    "task": 4,
    "cash": 2,
    "deal": 1
  }
}
  • eligible_count — total candidates scored before capping (all sources summed).
  • overflow_count — max(0, eligible_count - _BRIEF_CAP) — items that were scored but did not make the final 12. A high overflow is actionable: it signals the CEO has more fires than the brief can surface, and the org needs delegation (tiered briefs per §4b item 3) before the brief can shrink back to the CEO’s attention span.
  • candidate_sources — per-item-type counts of the full pool. Useful for debugging a sparse or unexpectedly dense brief.

Tests. 20 deterministic tests in backend/tests/ai/test_ranking.py, organized around eight golden-fixture acceptance categories (score-candidate unit, rank-and-cap unit, generation-meta integration, plus the 8 golden fixtures listed in the file header). The performance test in backend/tests/platform/test_performance.py::TestBriefGenerationQueryCount enforces ≤25 queries under the new always-gather-all-sources regime (up from ~18 when sections could be skipped).

Code location:

  • backend/apps/ai/services/ranking.py — score_candidate(), rank_and_cap(), ScoredCandidate, _BRIEF_CAP, _SECTION_LIMIT
  • backend/apps/ai/services/brief.py — integration in _generate_morning_brief_atomic(), _candidate_source_counts(), generation_meta storage

4. Data Model

briefs.Brief — organization FK, date (Date, unique per org-day), summary_text (str), generated_at (datetime), status draft/published.

briefs.BriefItem — brief FK, item_type (risk/decision/commitment/deal/highlight/status_update), priority (critical/high/medium/low), related_risk/related_decision/related_commitment/related_deal FKs (nullable), content_json, dismissed_at (nullable). The deal source (F50 U2 + U5) flags an operationally-stuck open deal — one with an overdue unpaid deposit (F50 U5, priority=high), an overdue scheduled CRM activity (priority=high), or no scheduled next activity (priority=medium) — with related_deal as its provenance (confidence_score=0.7, rule-based; no EvidenceSource, like commitment/decision items). A deal matching more than one condition yields exactly ONE item carrying its single most urgent suggestion (deposit-overdue > overdue-activity > no-activity); deposit-overdue deals rank first.

5. API Contract

MethodPathMin roleNotes
POST/api/briefs/generate/memberRe-generates today’s Brief (deletes existing BriefItems, recreates from live state); same-day repeat calls replace the previous Brief; abuse bounded by brief_generate throttle (10/hour)
GET/api/briefs/memberList briefs for org (paginated)
GET/api/briefs/<id>/memberDetail + items
POST/api/approvals/<id>/approve/current approverInline approve from Brief reuses F26 approval action; Brief item payload exposes approval_can_act so the CTA only renders when actionable

BriefItem response shape (evidence_sources)

Each BriefItem’s API representation includes a nested evidence_sources array:

{
  "evidence_sources": [
    {
      "id": "uuid",
      "source_type": "pulse_entry | task | project | manual_note | uploaded_file | chat_note | recurring_task | ext_confirmation",
      "title": "string",
      "snippet": "string (first 200 chars of content_text, null if empty)"
    }
  ]
}

Fallback: when a BriefItem has no evidence_sources of its own but has a related_risk, the risk’s evidence sources are returned instead. This ensures risk-derived brief items always carry provenance. Items with no evidence anywhere return [].

Related deal (F50 U2): a deal BriefItem carries related_deal (the deal id) plus a lightweight related_deal_title (string, null when absent) so the client can link straight to the deal. related_deal__company is select_related in the brief queryset — no N+1.

Performance: evidence_sources and related_risk__evidence_sources are prefetched in the brief queryset — no N+1.

Tenant isolation: evidence is scoped to the brief’s organization via the existing M2M + org-scoped brief queryset.

6. UI / UX

  • BriefPage.tsx — five sections, each independently loadable; section with no items collapses to a one-line placeholder (“Không có rủi ro đang mở 🎉”).
  • Inline actions render in the BriefItem card: “Giao việc” (assign task), “Quyết định” (decide), “Xem thêm” (view detail).
  • Evidence chip resolves everywhere (W-AIQ-05 read side, “evidence on every AI output” rail). Every EvidenceChip with ≥1 real evidence-source id — Brief, /intel/risks, /intel/commitments, /intel/decisions, /work/today, and (once a follow-up has been replied to) /intel/follow-ups — is now wrapped in frontend/src/components/page/EvidencePopover.tsx: clicking it opens a shadcn Popover that lazily fetches GET /api/evidence-sources/<id>/ (TanStack Query, one query per source id, enabled only while open) and renders the source’s title, a 4-line-clamped snippet (excerpt, F86 evidence_span highlighted via <mark> when it appears verbatim inside the excerpt), and — only when the source honestly resolves to a live destination — an “Open source” link. Destinations: pulse_entry → the already-proven /intel/pulse?highlight=<id>; task → /work/tasks (the app has no per-task detail route, same choice FollowUpsPage.tsx’s SOURCE_ROUTE already made); chat_note → the real /hub/messages?channel=…&topic=… / ?conversation=… thread, parsed from the legacy permalink metadata shape (frontend/src/lib/evidenceLinks.ts’s resolveChatPermalink — the stored permalink’s literal path 404s, so this parses it into the live route instead of either faking a link or reusing the dead one). Every other source_type (manual_note, uploaded_file, project, recurring_task, ext_confirmation, email) shows title + snippet only — no destination is faked. “Evidence missing” chips (0 source ids) stay exactly what they were: a plain, non-interactive badge, now with a title tooltip explaining why. One interaction pattern across all six consumers rather than Brief’s previous one-off <Link>-wrapping (which never actually fired — see the 2026-07-10 pulse_entry_id fix entry below). Backend: apps/pulses/serializers.py’s EvidenceSourceDetailSerializer (built for iOS EvidenceDetailView, reused as-is) gained evidence_span, pulse_entry_id, and permalink fields so a bare evidence-source id (Risks/Commitments/Decisions/Today only ever carry the id, not Brief’s richer embedded shape) can resolve a destination too. Cross-referenced in F10 risks §6.
  • Brief header: generated time, “Cập nhật Brief” button (re-generate), participation summary.
  • Share brief (growth / distribution object). The Brief is Lumi’s primary forwardable artifact — “what leaves Lumi and lands in front of someone who isn’t a user yet?” A Share brief button in the header (rendered only once a real brief exists) builds a client-side, chat-pasteable text artifact from the brief on screen and either opens the native share sheet (navigator.share, mobile → Zalo/WhatsApp) or copies it to the clipboard (desktop, with a confirmation toast). No backend, no new route — purely additive on F12. Artifact shape (pure helper frontend/src/lib/briefShare.ts): The action never changes meaning after a click: when native share is available, cancellation is silent and every other native-share rejection surfaces a retryable error; neither path copies to the clipboard. Clipboard is used only when native share was unavailable before the gesture, and the rendered action is labelled Copy in that state. While a native share promise is still active, another activation is ignored silently instead of calling navigator.share() again. The original browser share sheet remains user-controlled; once it settles, the next activation may open a new share sheet normally. This prevents the Web Share API’s overlapping-call InvalidStateError without hiding real permission, activation, payload, or transmission failures.
    • Line 1: stamps the sender’s identity so the forwarded artifact reads as a personal communication, not anonymous Lumi marketing copy (the Notion / Figma / Calendly distribution-object invariant — every artifact carries its sender). When the current organization name is available from the auth store: {org_name} · Lumi Brief · {brief_date} (or {org_name} · Lumi Brief when undated) — keys share_header_with_org / share_header_with_org_nodate. Defensive fallback: when the org name is missing or empty (auth store still hydrating on a deep link, edge case), the header falls back to the original Lumi Brief · {brief_date} / Lumi Brief (keys share_header / share_header_nodate) so sharing is never blocked. The org name is read once by the shared useBriefShare hook so both BriefPage and TodayPage stamp the identical header.
    • The summary lede (summary_text), if present.
    • Each non-empty section (same priority tiers as the page, shared via frontend/src/lib/briefSections.ts) with its localized label, then one • line per live item (dismissed_at excluded) — title (deal items use related_deal_title) plus its ceo_action_suggestion or description. No confidence/internal noise.
    • All-clear line when there are no live items.
    • Branded footer: when the CEO’s F59 referral code is available, the footer carries their personal referral link — — Made with Lumi · join free: https://runlumi.app/register?ref=<code> (share_footer_referral) — so each forward is an attributed, rewarded signup doorway (referrer + new signup each get 1 month premium, F59). When the code is still loading or its request failed, it falls back to the generic — Made with Lumi · runlumi.app (share_footer), so sharing is never blocked. The URL is built by the shared pure helper frontend/src/lib/referralLink.ts (buildReferralUrl), also used by /settings/referrals.
  • Referral reward nudge (growth / activation). A tasteful muted micro-caption sits directly under the header action buttons (rendered only with a real brief), so the CEO learns at the moment of sharing that forwarding rewards them — the awareness that turns the shared-brief artifact into a firing loop. Two states, driven by the already-fetched useReferralCode() data (times_used):
    • No friends joined yet (times_used 0 / code still loading) → inviting hint share_reward_hint: “Forward your brief — you and every friend who joins get a month of Lumi free.”
    • Friends joined (times_used > 0) → progress share_reward_progress: “{{count}} joined through your brief so far — keep forwarding, you both earn free months.” No backend, route, or extra request (reuses the same referral query already powering the footer); purely additive on F12.
  • Share brief as image (growth / visual distribution object). The most forwardable form of the artifact in image-first wedge markets (Zalo / WhatsApp / board decks) is a picture, not a text block — images get screenshotted and re-shared where pasted text does not. A second, additive “Share image” affordance (an icon button beside the text Share brief, rendered only once a real brief exists) builds a branded, client-side share card and either opens the native share sheet with the PNG attached (navigator.share({ files }), mobile → Zalo/WhatsApp one-tap) or downloads the PNG (desktop). The proven text-share path is untouched — this is purely additive. Card shape (pure helper frontend/src/lib/briefShareCard.ts, rasterised to PNG by the thin browser-only frontend/src/lib/briefImage.ts via <img>+<canvas>, zero new dependencies): File-share semantics are equally stable: a rejected native file-share attempt never opens a save/download prompt. Cancellation is silent; other rejections surface a retryable error. PNG download is reserved for browsers that cannot share the generated file before the gesture, and that fallback is labelled Download image rather than Share image.
    • A 1080×1350 portrait SVG on warm Lumi paper, brand palette inlined (the card renders outside the app CSS, so --color-* tokens can’t be referenced — the resolved DESIGN.md §16.1 hexes are used). Eyebrow (card_eyebrow) + brief_date; the brief’s summary_text as the headline (or card_headline_fallback when blank); a card_priorities section listing the top items by the shared groupBriefItems ordering (decisions/critical first), live items only (dismissed_at excluded), capped with a card_more (“+N more in Lumi”) overflow line; an all-clear state (share_all_clear) when there are no live items.
    • Identity letterhead. When the current organization.name is available from the auth store, the card stamps it as a small navy weight-600 line above the eyebrow — the Notion/Figma/Calendly identity-first invariant on the image artifact, mirroring the text share’s {org} · Lumi Brief · {date} header from the same auth source. Truncated to 32 code points so the longest legal org names can never overflow into the right-aligned date column (code-point-aware to keep emoji and CJK characters intact). When absent / empty / whitespace / undefined (auth store still hydrating on a deep link), no element is drawn and the SVG output is bit-for-bit identical to the pre-stamp output — sharing is never blocked. The org name is interpolated directly into the SVG (not translated, mirroring how referralUrl is passed verbatim through BriefShareCardOptions).
    • Doorway footer: card_footer_referral (“Made with Lumi · Join free → {url}”) carrying the CEO’s F59 referral link when available (same buildReferralUrl helper as the text share), falling back to the generic card_footer (“Made with Lumi · runlumi.app”) otherwise — so the image is never a dead end. No confidence/internal noise; the card only renders already- evidenced brief items, so no new AI output is produced (Rule 2 not triggered).
  • Share single brief item (growth / atomic distribution object). The whole-brief share is the multi-section memo a CEO sends to a board or co-founder; the atomic unit a CEO actually forwards day-to-day is one item — a single decision to a co-founder, a single risk to head of sales, a single commitment to finance. Forwarding a 12-item brief and saying “look at item 7” is friction; forwarding just “Decision: hire senior engineer — your call by Friday” is the natural human gesture. Every BriefItemCard therefore renders a Share2 ghost button in its action row (alongside Assign / Dismiss), only when the item is not dismissed. Clicking it builds a focused single-item artifact and either opens the native share sheet or copies it to the clipboard, mirroring the whole-brief flow exactly (same share_copied / share_error toasts, same AbortError cancel path). Artifact shape (pure helper frontend/src/lib/briefItemShare.ts):
    • Line 1: stamps the sender’s identity, mirroring the whole-brief share (PR #392) and the share-card image (PR #437) so all three forms of the forwarded brief read as identifiable letterhead, not anonymous Lumi marketing. When the organization.name is available from the auth store: {org_name} · Lumi Brief · {brief_date} (or {org_name} · Lumi Brief when undated) — keys share_header_with_org / share_header_with_org_nodate (reused verbatim from PR #392; zero new locale keys added by the per-item closure). Defensive fallback: when the org name is missing / empty / whitespace (auth store still hydrating on a deep link, edge case), the header falls back to the original Lumi Brief · {brief_date} / Lumi Brief (keys share_header / share_header_nodate) so sharing is never blocked. The org name is read once by the shared useBriefItemShare hook from the same useAuthStore selector as useBriefShare, so the per-item, whole-brief, and image artifacts can never drift on what “this brief came from” looks like.
    • The item title (deal items use related_deal_title).
    • The item description on the next line, when present.
    • \u{1F449} {ceo_action_suggestion} on the next line, when present — the arrow prefix lifts the action out as the visual “what you should do” line.
    • Branded or referral footer: same share_footer_referral / share_footer keys as the whole-brief share, so every per-item forward carries the same attributed doorway. Single source of truth orchestrator: useBriefItemShare (frontend/src/hooks/useBriefItemShare.ts).
  • i18n: brief (adds share_button, action_share, share_header, share_header_nodate, share_header_with_org, share_header_with_org_nodate, share_footer, share_footer_referral, share_all_clear, share_copied, share_error, share_reward_hint, share_reward_progress, share_image_button, share_image_saved, share_image_error, card_eyebrow, card_headline_fallback, card_priorities, card_more, card_footer, card_footer_referral).

7. Test Plan

#CaseTypeWhere
1Generation: risks, decisions, commitments, highlights includedbackendTestBrief::test_generate_brief (tests/api/test_briefs.py ~19)
2Same-day re-generation replaces BriefItems (delete+recreate)backendTestBrief::test_generate_brief_replaces_same_day_brief (tests/api/test_briefs.py ~46)
3Cross-tenant BriefItem isolationbackendTestBrief::test_brief_tenant_isolation (tests/api/test_briefs.py ~37)
4Dismiss → BriefItem dismissed_at stampedbackendTestBriefItemActions::test_dismiss_sets_dismissed_and_feedback (tests/api/test_briefs.py ~264)
5Risk resolve/dismiss → Brief syncbackendTestAC3_StatusChangeLeavesBrief::test_resolve_dismisses_existing_brief_items (tests/test_f10_risks_r32.py ~218)
6Generate + view + action flowe2edescribe("Brief")
7Share artifact: header, summary, sections, branded footerfrontendbriefShare.test.ts
8Share artifact: dismissed items excluded; all-clear; no-date; deal-title fallbackfrontendbriefShare.test.ts
9Share button copies to clipboard (no navigator.share) + success toastfrontendBriefPage.test.tsx
10Share button uses native share sheet when navigator.share existsfrontendBriefPage.test.tsx
11Share sheet cancelled (AbortError) → no clipboard fallback, no toastfrontendBriefPage.test.tsx
12Native share non-cancel failure → retryable error with no clipboard fallback; clipboard is used only when native share is unavailable, and clipboard failure surfaces an errorfrontendBriefPage.share.test.tsx · shareSemantics.test.ts
13Referral reward hint renders by the share action when no friends have joined yetfrontendBriefPage.test.tsx
14Referral progress ({{count}} joined) renders once times_used > 0frontendBriefPage.test.tsx
15Share card SVG: valid XML; eyebrow + date; summary headline (+ fallback when blank); stakes ordering (decisions before operations); dismissed excluded; all-clear statefrontendbriefShareCard.test.ts
16Share card SVG: referral doorway footer when url given vs generic footer when absent; XML-escapes special chars; “+N more” overflow line past the capfrontendbriefShareCard.test.ts
17Image action uses native file share when supported; native rejection never downloads; unsupported browsers receive an explicitly labelled Download image fallbackfrontend + e2eshareSemantics.test.ts · BriefPage.share.test.tsx · describe("Brief")
18Per-item share artifact: dated/undated header, title (deal-title fallback), description, 👉 action_suggestion line ordering, generic vs referral footerfrontendbriefItemShare.test.ts
18aPer-item share artifact header stamps the sender’s organization name when present ({org} · Lumi Brief · {date} / undated variant)frontendbriefItemShare.test.ts
18bPer-item share artifact header falls back to plain Lumi Brief · {date} when org name is missing / empty / whitespace / undefined (defensive — never " · Lumi Brief")frontendbriefItemShare.test.ts
18cPer-item share artifact carries both org-name stamp and F59 referral footer together — identity and growth doorway co-exist on the same forwarded artifactfrontendbriefItemShare.test.ts
19Per-item Share button on each BriefItemCard → native share sheet (when navigator.share exists) / clipboard fallback with single-item text, not the whole-brief artifactfrontendBriefPage.test.tsx
20Share artifact header stamps the sender’s organization name when present ({org} · Lumi Brief · {date})frontendbriefShare.test.ts
21Share artifact header falls back to plain Lumi Brief · {date} when org name is missing / empty / undefined (defensive — never " · Lumi Brief")frontendbriefShare.test.ts
22Share card SVG: org-name stamp above the eyebrow when present (navy weight-600, y=80); over-long names truncated to ≤32 code points; XML-escaped; emoji/CJK preservedfrontendbriefShareCard.test.ts
23Share card SVG: empty / whitespace / undefined org name → SVG is bit-for-bit identical to the pre-stamp output (defensive fallback, never injects a stray <text>)frontendbriefShareCard.test.ts
24A second text-share activation while the first native share promise is pending is a silent no-op; it does not call navigator.share() twice, and sharing becomes available again after the first promise settlesfrontendshareSemantics.test.ts

8. Current Behavior

Shipped across B-r32 (2026-06-10). POST /api/briefs/generate/ creates or returns today’s Brief for the org. BriefItems are generated from the last 24h of pulse data: open high/critical risks in §2, overdue decisions in §3, overdue/at-risk commitments in §4, participation summary in §1, highlights in §5.

summary_text is generated via keyword extraction (F08) — still generic (“Pulse processed. X items flagged.”) until LLM-mode extraction ships; this is an accepted limitation for mock mode.

Brief inline approval is not a separate backend endpoint. The CTA routes through F26’s POST /api/approvals/<id>/approve/, and the Brief payload now includes approval_can_act so the UI only renders that CTA for the current actionable approver.

Generation mid-crash: generate_morning_brief() now runs inside transaction.atomic(), so a failed generation does not leave a half-written Brief in the database. The next call can safely retry against the previous intact state.

Risk/decision lifecycle sync: resolve or dismiss a risk → linked BriefItem dismissed_at stamped (P2 resolved 2026-06-14 for F10; same mechanism for decisions). Re-generation re-pulls live state including newly resolved items.

9. Error States

#ErrorResponseNotes
ES1Cross-tenant brief access404TestBrief::test_brief_tenant_isolation (tests/api/test_briefs.py ~37)
ES2Generate with no pulse submissions yesterday200 — Brief generated with empty sections + context noteNo error; empty state has placeholder copy
ES3Generate mid-crash (partial Brief)no partial Brief persistsgenerate_morning_brief now runs inside transaction.atomic(); a failed generation does not leave a half-written Brief behind
ES4Re-generation on same dayReplaces BriefItems (delete+recreate); abuse bounded by 10/hour rate throttle (brief_generate)TestBrief::test_generate_brief_replaces_same_day_brief (tests/api/test_briefs.py ~46)
ES5Inline approve shown to non-actionable userCTA hidden in Brief UI; direct approval API call still returns 403Brief uses approval_can_act for affordance gating; F26 remains the server-side authorization gate
ES6Frontend section load failurePer-section useQueries failure shows section error state; other sections still load (fixed 2026-06-14)test_brief_section_independent_failure
ES7Dismiss already-dismissed BriefItem200 — idempotent (re-stamps dismissed_at)test_dismiss_briefitem_idempotent
ES8Source Risk/Decision deleted after appearing in Brieflinked BriefItem auto-dismisses before FK nullsapps/operations/signals.py stamps dismissed_at on linked BriefItems before SET_NULL, so stale items disappear from the active queue instead of lingering until re-generation
ES9generate called by viewer (non-member)403Role gate on generate endpoint
ES10No organization on request400 (tenant middleware)Standard; logged; not Brief-specific

10. Diagrams & Docs Touched

backend/DIAGRAMS.md Brief generation flow · frontend/DIAGRAMS.md route tree · GLOSSARY (Brief, BriefItem) · F10 spec (risk→Brief sync) · F11 spec (decision→Brief sync).

11. Implementation Audit (2026-06-14)

#ClaimStatusEvidence
1POST /api/briefs/generate/ — creates or replaces today’s Brief, returns with all BriefItems✅ matchB-r32; TestBrief::test_generate_brief (tests/api/test_briefs.py ~19)
2Same-day re-generation replaces BriefItems (delete+recreate); 10/hour throttle prevents abuse✅ matchTestBrief::test_generate_brief_replaces_same_day_brief (tests/api/test_briefs.py ~46)
3§2 Risks: open high/critical, ordered by severity✅ matchgenerate_morning_brief filters severity__in=["high","critical"], status="open"; P1 (F10) resolved 2026-06-14
4§3 Decisions: overdue first, critical-urgency badge✅ matchdecision_needed_by ≤ today → priority="critical"; ordering by decision_needed_by
5§4 Commitments: overdue/at-risk (not all)✅ matchCommitment Brief filter uses due_date__lte=today; test_overdue_commitment_in_brief
6Risk resolve/dismiss → BriefItem dismissed_at stamped✅ matchP2 (F10) resolved 2026-06-14; TestAC3_StatusChangeLeavesBrief::test_resolve_dismisses_existing_brief_items (tests/test_f10_risks_r32.py ~218); test_dismiss_risk_dismisses_brief_item
7Decision decide/cancel → BriefItem dismissed_at stamped✅ match2026-07-01: apps/operations/signals.py dismisses linked decision BriefItems on open -> decided/cancelled; covered by TestDecision::test_decide_dismisses_linked_brief_items and test_cancel_decision_dismisses_linked_brief_items
8summary_text is generic in mock-extraction mode✅ match (accepted limitation)“Pulse processed. X items flagged.” until LLM-mode F08; test_generate_returns_summary_text
9Generation not wrapped in transaction.atomic()✅ fixedgenerate_morning_brief delegates to _generate_morning_brief_atomic inside transaction.atomic() — brief.items.delete() + bulk_create + summary save are one atomic unit (2026-06-26)
10Inline approval from Brief only appears for actionable approval items and reuses F26 authorization✅ match2026-07-01: BriefItemSerializer exposes approval_can_act; BriefPage.tsx only renders the Approve CTA when true; action still routes to /api/approvals/<id>/approve/, which remains guarded by current-approver checks (test_only_current_approver_can_act)
11Frontend per-section independent failure (useQueries)✅ matchFixed 2026-06-14; test_brief_section_independent_failure; each section shows own error state
12BriefItem linked to deleted Risk/Decision no longer stays in the active Brief queue✅ fixed2026-07-01: apps/operations/signals.py dismisses linked BriefItems before SET_NULL on Risk/Decision delete; covered by TestRisk::test_risk_delete_dismisses_linked_brief_items and TestF11DecisionsG32::test_decision_delete_dismisses_linked_brief_items
13Visual QC: refreshing the Brief does not throw a success toast over the page header and primary CTA✅ match2026-06-18 visual QC: BriefPage.tsx relies on inline refresh feedback (generated_at pulse) instead of a redundant toast; BriefPage.test.tsx; frontend/screenshots/04-brief/02-brief-after-generate.png

Summary: 13/13 match. Gap 9 (transaction.atomic) fixed 2026-06-26; gap 10 (inline approve affordance) and gap 12 (source deletion orphaning) fixed 2026-07-01.

Edge Cases Catalog (formerly §10, 2026-06-12; 23 items): Key items integrated into audit rows 9–12 and §9 error states. Full catalog includes: D-r26 re-verification items (all resolved 2026-06-14), generation with zero pulses (ES2), idempotency edge (ES4), inline approve affordance/actionability (row 10), section-failure independence (row 11), summary_text placeholder (row 8), source deletion lifecycle (row 12, resolved 2026-07-01), concurrent generation race (not tested), BriefItem priority escalation over time (no scheduled recalculation), §5 Highlights never populated in mock mode, multi-timezone org brief timing, Brief date boundary (midnight generation for cross-midnight submissions). Full catalog preserved in git history (2026-06-12 changelog entry).

12. Open Questions / Decisions

  • Inline approval architecture: Keep Brief as a surface over F26, not a second approval subsystem. The correct contract is: Brief items expose whether approval is actionable (approval_can_act), and any action routes through /api/approvals/<id>/approve/ so authorization stays single-source.
  • summary_text quality: Will improve automatically when LLM-mode extraction (F08) ships. No interim action needed.

Forward reference — F97: BriefItemSerializer now exposes project_name and project_id SerializerMethodFields derived from related_risk.project, related_decision_request.project, and the task→project FK chain. BriefPage renders a project badge linking to /work/projects/:id/board. See docs/specs/F97-project-progress-updates.md.

13. Bug / Change Log

2026-07-21 — Shared mobile restores the honest no-brief state

Type: Mobile API-contract regression fix

  • GET /api/briefs/today/ returns the endpoint-only sentinel status: "not_generated" when no morning Brief row exists. This value is not part of persisted Brief.STATUS_CHOICES, but it is part of the response contract decoded by BriefResponse.
  • Shared Android/iOS presentation now maps both not_generated and persisted draft to the localized no-brief explanation with a Generate action. generated, reviewed, and sent remain renderable.
  • Critical flow 113 verifies the empty explanation, Generate action, and absence of Regenerate against a disposable no-Brief organization. Its exact-APK emulator result is mechanics evidence; signed named-Android and signed-iPhone proof remain open.

Tests: BriefScreenModelTest, StatusVocabularyContractTest, and .maestro/critical/113_android_brief_not_generated_empty_state.yaml via scripts/run-android-brief-empty-state-proof.sh.

2026-07-17 — Summary-first item rendering

Type: Frontend readiness boundary

  • A generated Brief now commits its truthful summary and metadata before starting the lazy item renderer. Two animation frames separate the summary commit from item-module discovery; the existing fixed-height Suspense region prevents layout shift without announcing false loading.
  • Item selection, ranking, data, project/type views, assignment, dismissal, evidence, and sharing behavior are unchanged. A frame-controlled unit test proves details remain reachable immediately after the boundary.
  • Focused candidate evidence records Brief at 3344 ms LCP and 289.793 kB versus exact-main 3604 ms and 289.782 kB. This is readiness evidence, not a score promotion.

Tests: frontend/src/test/intel/BriefPage.test.tsx (F12.AC002.SUMMARY_PAINT).

2026-07-16 — Read-first dependency ownership

Type: Performance behavior preservation

The default type-grouped Brief no longer pays for project grouping, assignment and dismissal dialogs, the assignment members query, share implementations, or an empty “What Lumi did” section. Pointer/focus intent warms interactive modules; persisted project mode and every existing action keep their prior behavior. Focused Brief and share suites remain green, and F40 owns the executable custody proof and route-budget evidence.

2026-08-11 — Ignore overlapping native-share activation (feedback #30 retest)

Type: Bug fix (shared frontend interaction contract)

The first semantics repair correctly kept unsupported fallback separate from native-share failure, but its repeated-gesture proof awaited the first share before starting the second. A tester could activate Share again while the browser’s first share promise was still pending; the Web Share API then rejects the overlapping call with InvalidStateError, and Lumi showed retry guidance even though the original share sheet was still active. The shared helper now admits only one native share call per Navigator: overlapping activations are silent no-ops, the original sheet remains owned by the browser, and the guard is released in finally so a later deliberate share works. Other native errors remain visible and never fall back to copy or download.

2026-07-15 — Share actions keep one meaning across repeated gestures (feedback #30/#31)

Type: Bug fix (shared frontend interaction contract)

Text and image sharing now share one capability-aware browser contract. If native share is available before the gesture, success stays native, cancellation is silent, and every other rejection surfaces retry guidance without silently copying text or downloading an image. Copy and PNG download remain available only when native text/file sharing is unsupported from the outset, and those controls render as Copy / Download image before they are clicked. The same helper and labels cover Brief, Today, Brief items, Risks, Decisions, Commitments, and Pulse; repeated-click, native-failure, cancellation, file-support, and fallback regressions protect the shared semantics.

2026-07-15 — Image action copy matches its share-first behavior (feedback #29)

Type: Bug fix (shared frontend copy)

The shared image-action label is now “Share image” / “Chia sẻ ảnh” instead of “Save as image” / “Lưu thành ảnh”. The same brief.share_image_button key powers Brief, Today, Risks, Decisions, and Commitments, so every image-share tooltip now describes the primary mobile action while the documented desktop download remains a fallback. A bilingual page regression and the existing browser download proof cover both the label contract and fallback behavior.

2026-07-13 — Enforce manager-only brief generation

POST /api/briefs/generate/ now requires IsOrgManager; brief reads remain available to active members, while assign/dismiss already require managers. Contract Graph proofs cover generation, tenant isolation, manager allow, viewer denial, and item action boundaries. UI/browser/native evidence remains explicit.

2026-07-12 — Referral URLs remain complete in Brief share images (feedback #51)

Type: Bug Fix (share-card layout)

Summary:

  • The doorway footer now wraps at 56 code points, keeping the preamble on one line and a normal referral URL on the second line without adding a false truncation ellipsis.
  • The SVG remains XML-valid and the full referral URL is covered by a regression test.

Tests:

  • frontend/src/test/intel/briefShareCard.test.ts asserts the full https://runlumi.app/register?ref=... URL is present without an ellipsis.

Type: Feature (P0 — “evidence on every AI output” rail, read side)

Summary:

  • Follows the same-day pulse_entry_id fix below. That fix made the backend data honest; this closes the read side the original walk also found: frontend/src/components/page/EvidenceChip.tsx rendered a “1 source” / “Evidence missing” badge with zero interaction on every one of the 6 consumers the audit measured (Brief, Risks, Commitments, Decisions, Follow-ups, Today) — even a chip that had a real, resolvable source behind it was inert.
  • Added frontend/src/components/page/EvidencePopover.tsx: wraps EvidenceChip in a shadcn Popover trigger whenever the caller passes ≥1 real evidence-source id. Opening it lazily fetches each id via GET /api/evidence-sources/<id>/ (new hook frontend/src/hooks/queries/useEvidenceSourcesDetail — TanStack Query useQueries, enabled only while open, 5-minute staleTime) and renders title + a 4-line-clamped snippet (highlighting the F86 evidence_span verbatim substring inside it, when present) + an “Open source” link — but only when frontend/src/lib/evidenceLinks.ts’s resolveEvidenceSourceLink can point at something real (never a guess).
  • Destination rules (pure, unit-tested in evidenceLinks.test.ts): pulse_entry → /intel/pulse?highlight=<id> (the destination the same-day fix below already proved works); task → /work/tasks (no per-task detail route exists yet — the same honest choice FollowUpsPage.tsx’s SOURCE_ROUTE made before this change); chat_note → resolveChatPermalink() parses the metadata_json.permalink legacy path shape (/messages/channels/<id>?topic=… / /messages/conversations/<id>, stamped by apps/messaging/services.py’s flag_signal) into the real, currently-live /hub/messages?channel=…&topic=… / ?conversation=… route — using the stored permalink verbatim would 404, since the messaging surface moved to query-param deep links after that stamping code was written. Everything else (manual_note, uploaded_file, project, recurring_task, ext_confirmation, email) gets no link — title/snippet only.
  • “Evidence missing” chips (0 source ids) are unchanged in substance — same static, non-interactive EvidenceChip — but now carry a title tooltip (EvidenceChip gained an optional title passthrough prop) explaining there’s nothing to open, instead of silently doing nothing on click/hover.
  • Backend (apps/pulses/serializers.py): EvidenceSourceDetailSerializer (built for iOS EvidenceDetailView — GET /api/evidence-sources/<id>/ and its /api/pulses/evidence-sources/<id>/ router twin, reused as-is rather than adding a second endpoint) gained evidence_span, pulse_entry_id, and permalink fields. Needed because Risks/Commitments/Decisions/Today evidence chips only ever carry a bare EvidenceSource id (evidence_sources: string[]) — unlike Brief’s richer embedded EvidenceSourceBrief[] shape, which already had pulse_entry_id — so without this addition only Brief’s popover could ever resolve a destination. 5 new backend tests in tests/api/test_ai_infra.py::TestEvidenceSourceDetail.
  • Wired into all 6 consumers: BriefPage.tsx (replaces the one-off pulse_entry_id-gated <Link>-wrap that the entry below shows never actually fired), RisksPage.tsx, CommitmentsPage.tsx, DecisionsPage.tsx, TodayPage.tsx (all 3 trust-row helpers), and FollowUpsPage.tsx (interactive only once a follow-up has been replied to — item.reply_evidence_source is the only real EvidenceSource id a FollowUpRequest ever carries; before a reply exists there is nothing to show, so the chip stays the same plain badge it always was). See F10 risks §6 for the Risks-side cross-reference.
  • Also fixed this walk’s own tooling bug while re-measuring: frontend/scripts/audit-walks/w-aiq-05.mjs’s PAGES list pointed at /intel/followups (no hyphen) — the app’s real route is /intel/follow-ups (routeTree.ts) — so every “Follow-ups” row in the original walk was silently re-scoring /work/today a second time under the wrong label (TanStack Router’s catch-all redirects an unmatched path there). Fixed the route and taught the script to recognize the new button[data-slot="evidence-chip-trigger"] popover-trigger pattern alongside plain <a> links, so it can honestly count both. Re-run results appended to docs/tests/evidence/web/W-AIQ-05/walk.md as a new dated section (history preserved, not overwritten).
  • Tests: frontend/src/test/lib/evidenceLinks.test.ts (pure link/highlight-resolution rules, 17 cases), frontend/src/test/_shared/EvidencePopover.test.tsx (open → fetch → render snippet + link; no-destination source shows no link; load-error fallback; missing-evidence chip stays inert, 4 cases), frontend/src/test/_shared/page-primitives.test.tsx (new title-passthrough case). i18n/locales/en|vi/evidence.json new namespace (propagated to all 61 locales via propagate-new-i18n-keys.mjs, parity-checked).

Impact:

  • Every evidence chip in the app now has a real, honest interaction — closes the read-side half of the “evidence on every AI output” rail this row measures. No backend write-path or schema change; the two new serializer fields are additive and read-only.

2026-07-10 — W-AIQ-05: evidence chips were dead in production — pulse_entry_id fixed at the source

Type: Bug Fix (P0 — “evidence on every AI output” rail)

Summary:

  • Audit docs/tests/evidence/web/W-AIQ-05/walk.md found 0 of 71 evidence chips clickable across Brief/Risks/Commitments/Decisions/Follow-ups/Today in production-shaped data. BriefPage.tsx is the only page wired to turn a chip into a <Link> to /intel/pulse?highlight=<id>, and it only does so when evidence_sources[].pulse_entry_id is present.
  • Root cause: apps/pulses/views.py’s submit_pulse — the primary, real-world pulse submission path — created the source_type="pulse_entry" EvidenceSource with metadata_json=pulse.answers_json, clobbering the {"pulse_entry_id": ...} shape BriefEvidenceSourceSerializer.get_pulse_entry_id() (apps/briefs/serializers.py) reads. apps/ai/services/extraction.py’s fallback-create path wrote the correct shape, but its “reuse the most recent pulse_entry EvidenceSource” query almost always found and reused the broken row submit_pulse had just created, so the correctly-tagged create path was rarely reached.
  • Fix: metadata_json now carries both the linkage and the original payload — {"pulse_entry_id": str(pulse.id), "answers": pulse.answers_json} — in every pulse_entry EvidenceSource creation path: apps/pulses/views.py (submit_pulse), apps/telegram/handlers.py (handle_pulse_reply, same bug), and apps/accounts/management/commands/seed_demo_lumi.py (both evidence-creation blocks — the second one had metadata_json unset entirely).
  • Same-bug sweep: grepped every other EvidenceSource.objects.create site (chat_note, uploaded_file, manual_note, email, ext_confirmation, recurring_task, task) and every reader of EvidenceSource.metadata_json — no other source_type currently has a serializer reading an id key back out of metadata_json, so this was an isolated bug, not a class of bugs.
  • Backfill: python manage.py backfill_evidence_pulse_ids [--dry-run] (apps/pulses/management/commands/backfill_evidence_pulse_ids.py) links legacy rows missing pulse_entry_id by matching (organization, created_by) + (content_text == pulse.raw_text or the legacy-bug metadata_json == pulse.answers_json shape); ambiguous or unmatched rows are left untouched and counted honestly, never guessed at. Idempotent — a linked row’s query no longer selects it.
  • Tests added: tests/api/test_pulses.py::test_submit_pulse_evidence_carries_pulse_entry_id; tests/api/test_brief_evidence.py::test_evidence_pulse_entry_id_resolves_from_metadata_json + test_evidence_pulse_entry_id_null_when_metadata_missing_it; tests/pulse/test_backfill_evidence_pulse_ids.py (8 cases: link from legacy-bug shape, link from null metadata, dry-run, idempotent re-run, already-linked row untouched, ambiguous duplicate raw_text, no created_by, cross-tenant safety).

Impact:

  • EvidenceSource.metadata_json shape change for source_type="pulse_entry" rows created going forward; no reader outside this fix depended on the old (broken) shape.
  • Legacy rows require running the backfill command in each environment; dry-run output should be reviewed before the live run.

2026-07-02 — Decision: <1h brief idempotency dropped — not needed

Type: Owner Decision / Documentation Change

Summary:

  • The <1h brief idempotency gate (“generate returns the existing Brief if generated < 1h ago”) was flagged in the prior 2026-07-02 §13 entry as never implemented. The product owner has reviewed and decided this capability is not needed.
  • Generation regenerates the Brief on every call; abuse is bounded by the brief_generate throttle (10/hour, confirmed in backend/config/settings/base.py); same-day stability is provided by the delete+recreate semantics.
  • The §4b scale note that referenced “(idempotent within 1h per §2)” has been corrected to remove the stale parenthetical — the caching suggestion for LLM re-opens remains valid as a future scale improvement, decoupled from the dropped idempotency promise.
  • The “FLAG FOR HUMAN REVIEW” note in the prior 2026-07-02 entry is hereby resolved: no implementation is required; the spec now honestly reflects shipped behavior with no open gaps on this point.

Impact:

  • Documentation only. No code, API, or behavior changed.

2026-07-02 — Correct stale test names and <1h idempotency claim (batch-1 spec audit)

Type: Documentation Change

Summary:

  • §2 AC: Removed the <1h idempotency acceptance criterion (“Given the Brief is already current (generated < 1h ago), then generate returns the existing Brief”). This AC was never implemented. generate_morning_brief() in backend/apps/ai/services.py always calls _generate_morning_brief_atomic(), which unconditionally deletes existing BriefItems and rebuilds from live state. Abuse is bounded by the brief_generate Django REST Framework throttle at 10/hour (set in backend/config/settings/base.py), but that is a rate limit, not an idempotency gate. The spec now describes the actual behavior: same-day re-calls replace the Brief.

FLAG FOR HUMAN REVIEW: The <1h idempotency gate described in the original B-r32 spec was never built. The original design intended that re-generating within an hour would return the cached Brief, but the implementation chose always-regenerate instead. The 10/hour throttle provides abuse protection. This divergence was never documented. If the original idempotent-within-1h behavior is still desired, it needs to be implemented and tested; the current spec now honestly reflects the shipped behavior.

  • §5 API: POST /api/briefs/generate/ note changed from Idempotent within 1h; returns Brief with all BriefItems to reflect delete+recreate semantics.
  • §7 rows 1-5: stale test class TestBriefGeneration never existed by that name; corrected to actual classes and methods:
    • Row 1: → TestBrief::test_generate_brief (tests/api/test_briefs.py ~19)
    • Row 2 + description: Idempotent within 1h → Same-day re-generation replaces BriefItems; test → TestBrief::test_generate_brief_replaces_same_day_brief (~46)
    • Row 3: → TestBrief::test_brief_tenant_isolation (~37)
    • Row 4: test_dismiss_stamps_dismissed_at → TestBriefItemActions::test_dismiss_sets_dismissed_and_feedback (~264)
    • Row 5: test_resolve_risk_dismisses_brief_item → TestAC3_StatusChangeLeavesBrief::test_resolve_dismisses_existing_brief_items (tests/test_f10_risks_r32.py ~218)
  • §9 ES1: test name corrected to TestBrief::test_brief_tenant_isolation.
  • §9 ES4: entry renamed from Re-generation within 1h / Returns existing Brief (idempotent) to Re-generation on same day / Replaces BriefItems; test name corrected.
  • §11 rows 1, 2, 6: test names corrected to match above.

Impact:

  • Documentation only. No code, API, or behavior changed.

2026-07-01 — Brief source lifecycle now dismisses stale items on close/delete

Type: Backend lifecycle fix

Summary:

  • Added apps/operations/signals.py so linked Brief items are dismissed when a decision closes (open -> decided/cancelled) and when a Risk or Decision source is deleted.
  • This keeps old Brief cards from lingering in the active CEO queue after the underlying source no longer needs action or no longer exists.

Reason:

  • F12 still had a real implementation gap: deleting a Risk/Decision could leave a stale active Brief item behind, and decision actions were not dismissing linked Brief items even though the spec claimed they did.

Impact:

  • The active Brief queue now stays aligned with live operational state without requiring a manual re-generation pass.
  • SET_NULL history is preserved, but the stale card is no longer treated as an open CEO action.

Tests:

  • cd backend && DJANGO_SETTINGS_MODULE=config.settings.test ./.venv/bin/python -m pytest tests/test_api.py -k "risk_delete_dismisses_linked_brief_items or decide_dismisses_linked_brief_items or cancel_decision_dismisses_linked_brief_items" -x --tb=short
  • cd backend && DJANGO_SETTINGS_MODULE=config.settings.test ./.venv/bin/python -m pytest tests/test_red_r32_g.py -k "decision_delete_dismisses_linked_brief_items" -x --tb=short

2026-07-01 — Brief inline approval now respects approval actionability

Type: Backend + frontend affordance fix

Summary:

  • Added approval_can_act to BriefItemSerializer so Brief payloads carry whether the current member is the actionable approver for a linked approval item.
  • Updated BriefPage.tsx to render the inline Approve CTA only when approval_can_act is true, while still routing the action through the existing F26 approvals API.
  • Added backend proof in backend/tests/test_approvals.py and frontend proof in frontend/src/test/BriefPage.test.tsx.

Reason:

  • The prior spec note described a non-existent /api/briefs/<id>/approve/ backend gap. The real product debt was narrower: the Brief UI could show an approve button without knowing whether the current member could actually act.

Impact:

  • The Brief no longer invites unauthorized approval attempts from the UI.
  • Authorization remains single-source in F26 instead of duplicating approval logic inside F12.

Tests:

  • cd backend && DJANGO_SETTINGS_MODULE=config.settings.test ./.venv/bin/python -m pytest tests/test_approvals.py -x --tb=short
  • cd frontend && pnpm test -- src/test/BriefPage.test.tsx

2026-06-29 — F90 field exceptions join the Brief candidate path

Type: Feature Integration

Summary:

  • Morning Brief generation now reads grounded unresolved field exceptions from the F90 execution ledger when a field task is still blocked, waiting on materials, waiting on approval, or marked for a return visit.
  • The resulting BriefItems link real EvidenceSource rows from the field event/worklog proof and also set related_task to the live field task, so the Brief links back to the existing execution object instead of offering a duplicate assign action.

Reason:

  • F90 shipped the task-level ledger first. The Brief needed the same narrow escalation path so field-service exceptions reach the CEO through the existing queue instead of a second monitoring surface.

Impact:

  • CEOs can now see field blockers in the same Brief alongside decisions, risks, commitments, and overdue work, while routine field milestones still stay out of the executive queue.

Tests:

  • cd backend && DJANGO_SETTINGS_MODULE=config.settings.test ./.venv/bin/python -m pytest tests/test_field_execution.py -k brief -x --tb=short

2026-06-26 — Brief routing keyword expansion + transaction.atomic hardening

Type: Feature (extraction quality / one-pager coverage)

Summary:

Atomicity (§11 row 9):

  • generate_morning_brief delegates to _generate_morning_brief_atomic inside transaction.atomic() — brief.items.delete() + bulk_create + summary_text save are a single atomic unit; no partial-brief state on crash.

Brief routing keyword expansion (_generate_morning_brief_atomic):

ListAdditionsReasoning
_CASH_KWhết tiền, không có tiền, vốn hết, bị phạt, phạt tiền, tiền phạtOperational cash dry-up and financial penalties are cash-section signals, not generic risks
_BLOCKED_KWcách ly, bị cách ly, bị thương, nhập viện, quá tải, không đủ nhân sự, thiếu ngườiWorkforce unavailability and capacity overload → surfaces as unblock action in brief
_COMPLAINT_KWhủy, hủy đơn, khách hủyOrder cancellations are a customer complaint signal, not generic operations
_MISSING_KWchờ giao, chờ vềSupply-in-transit adds to missing-materials brief cluster

Tests: covered via test_pulse_v2_p3.py (pattern extraction), test_brief_commitment.py (brief generation), and integration through test_ai_synthesis.py.

2026-06-22 — Growth/distribution-object: close the identity-stamp arc — the per-item TEXT share now also stamps the sender’s organization name (third and final form of the forwarded brief)

Type: Feature (growth / distribution-object identity — additive on the per-item form of the artifact, no new surface, zero new locale keys)

  • PR #392 stamped the org name on the whole-brief text artifact; PR #437 stamped it on the whole-brief image card. The third form of the forwarded brief — the per-item text share (buildBriefItemShareText via useBriefItemShare, on every BriefItemCard Share button) — still emitted the anonymous Lumi Brief · {brief_date} header. The per-item share is the highest-volume CEO share gesture per the recent per-commitment / per-risk / per-decision rollouts (#413 / #419 / #421 / #434): “forward this one decision to my co-founder”, “send this risk to head of sales”, “ping this commitment to finance”. Closing the gap is what makes the identity letterhead invariant — “every artifact carries its sender” — finally hold across all three forms of the brief artifact.
  • Closed by adding an optional orgName field to buildBriefItemShareText in frontend/src/lib/briefItemShare.ts. When present and trimmed non-empty, line 1 becomes {org_name} · Lumi Brief · {brief_date} (or {org_name} · Lumi Brief undated) via the existing share_header_with_org / share_header_with_org_nodate keys — the same keys PR #392 already added to all 62 locales, reused verbatim, so zero new locale entries ship. When absent / empty / whitespace / undefined (defensive — auth store still hydrating on a deep link), the header falls back to the original anonymous form so sharing is never blocked. useBriefItemShare reads useAuthStore((s) => s.organization?.name) (the same selector as useBriefShare), so the per-item, whole-brief, and image artifacts can never drift on what “this brief came from” looks like.
  • Frontend-only — no backend, no API, no migration, no route, no new dependency, no AI output, no design-token change, zero new locale keys. No registry scope change (Rule 6 clear; F12 + F02 both already ✅). No diagram change (no model/route/state change). No POLISH change (additive growth capability on a shipped surface).
  • Growth metric this bets on: per-item-share-attributed signups per forwarded item. The per-item share is the highest-volume forward — most CEOs send single decisions / risks / commitments many times per week, vs forwarding the whole brief once. The hypothesis from #392 and #437 extends to the highest-volume artifact: identifiable > anonymous on the gesture that spreads most.

Test plan: 4 new cases in briefItemShare.test.ts (org-name stamp on the dated / undated header; defensive fallback to anonymous header when empty / whitespace / undefined; org name + referral footer co-exist on a single artifact — identity and growth doorway hold together, not as either/or). All 10 existing briefItemShare.test.ts cases stay green without modification (the parameter is strictly additive and the default path bit-for-bit preserves the pre-change header). briefShare.test.ts (14 cases), briefShareCard.test.ts (19 cases), BriefPage.test.tsx (22 cases), and TodayPage.test.tsx (28 cases) all stay green — the surrounding text, image, page, and Today share contracts are unchanged.

Local gates green before PR: tsc --noEmit 0 errors; pnpm test for affected suites green (briefItemShare 13/13, briefShare 14/14, briefShareCard 19/19, BriefPage 22/22, TodayPage 28/28 — 96/96); node scripts/check-design-tokens.mjs clean (no React-tree UI change); pnpm validate-i18n parity green across all 62 locales (zero key changes — the org-name keys already exist from PR #392).

2026-06-22 — Growth/distribution-object: stamp the sender’s organization name into the brief share-CARD image header (mirror PR #392 on the visual artifact)

Type: Feature (growth / distribution-object identity — additive on the image form of the artifact, no new surface)

  • PR #392 (commit 1b46d04f) stamped the sender’s organization name into the text share artifact’s header ({org_name} · Lumi Brief · {date}) — the Notion/Figma/Calendly identity-first invariant on the line a receiver actually sees first in a WhatsApp / Zalo / email preview. What that PR did not touch was the image form of the artifact (the branded PNG card shipped in #310), which is the most-forwarded form in image-first wedge markets (Zalo / WhatsApp / board decks / screenshots-of-screenshots). The card’s eyebrow still read just Morning Brief 2026-06-22 with no sender — the channel where Lumi spreads most was the channel where the sender-identity gap was widest.
  • Closed by adding an optional orgName field to BriefShareCardOptions in frontend/src/lib/briefShareCard.ts. When present and trimmed non-empty, the card stamps the org name as a small navy weight-600 line at the top of the card (y=80, PAD-aligned, no letter-spacing — distinct typography from the slate-letter-spaced eyebrow below it), truncated to ≤32 code points via the existing code-point-aware truncate helper so the longest legal org names (emoji and CJK safe) can never overflow into the right-aligned date. The eyebrow row, divider, headline, priorities block, and footer band positions are all unchanged — the identity line sits in the dead space between the white-sheet top (y=40) and the eyebrow box (y=98) with ~18px headroom. When absent / empty / whitespace / undefined (defensive — auth store still hydrating on a deep link), no element is drawn and the SVG output is bit-for-bit identical to the pre-stamp output, so existing card assertions and every share taken in the past stay valid.
  • BriefPage.tsx’s handleShareImage reads organization?.name from useAuthStore (mirroring useBriefShare’s pattern from #392) and threads it as orgName through buildBriefShareCardSvg(briefData, briefItems, t, { referralUrl, orgName }). No layout / no copy change to the page itself.
  • Frontend-only — no backend, no API, no migration, no route, no new dependency, no AI output, no design-token change, zero new locale keys (the org name is interpolated directly into the SVG, mirroring how referralUrl is passed verbatim through BriefShareCardOptions). No registry scope change (Rule 6 clear; F12 + F02 both already ✅). No diagram change.
  • Growth metric this bets on: share-card-attributed signups per forwarded image (the downstream conversion #310 opened and #392 proved on the text variant) and open-rate / “tap to open” on forwarded image previews in WhatsApp / Zalo / Messenger / Viber — the image’s top-left identity stamp is what shows most prominently in chat thumbnails, so an identifiable card should out-convert the anonymous current state. Hypothesis from #392 extends here: identifiable > anonymous on the channel that spreads most.

Test plan: 5 new cases in briefShareCard.test.ts (org-name stamp rendered above the eyebrow with navy weight-600 typography; bit-for-bit-identical SVG when orgName is empty/whitespace/undefined/omitted; over-long names truncated to ≤32 code points; XML escape on & < > " '; emoji/surrogate-pair safety on long org names). All 13 existing briefShareCard.test.ts cases stay green without modification (the parameter is strictly additive and the default path bit-for-bit preserves the pre-change SVG). BriefPage.test.tsx (22 cases), briefShare.test.ts (14 cases), briefItemShare.test.ts (8 cases), and TodayPage.test.tsx (28 cases) all stay green — the text-share, per-item-share, and Today-share contracts are unchanged.

Local gates green before PR: tsc --noEmit 0 errors; pnpm test for affected suites green (briefShareCard 19/19, briefShare 14/14, BriefPage 22/22, briefItemShare 8/8, TodayPage 28/28 — 91/91); node scripts/check-design-tokens.mjs clean (no React-tree UI change; the SVG card continues to use inlined DESIGN.md §16.1 hexes per its file-level comment, exempt from the stock-palette check); pnpm validate-i18n parity green across all 62 locales (no key changes).

2026-06-22 — Growth/distribution: per-item share — every decision, risk, and commitment is its own focused doorway

The whole-brief share is the right artifact for “send my morning brief to the board”, but the atomic unit a CEO actually forwards day-to-day is one item — one decision to a co-founder, one risk to head of sales, one commitment to finance. Forwarding a 12-item brief and saying “look at item 7” is friction; forwarding just “Decision: hire senior engineer — your call by Friday” is the natural human gesture. Notion, Calendly, and Figma all won viral distribution at the atomic-unit share (one page, one invite, one file), not the workspace share.

Every BriefItemCard now renders a ghost Share2 icon button in its action row (alongside Assign / Dismiss), hidden only on dismissed items. Clicking it calls the new useBriefItemShare hook, which builds a focused single-item artifact via the new pure helper frontend/src/lib/briefItemShare.ts and either opens the native share sheet (mobile → Zalo/WhatsApp) or copies to clipboard (desktop), mirroring useBriefShare’s AbortError-cancel / clipboard-fallback / toast flow exactly so the two paths can never drift. Artifact shape:

Lumi Brief · {brief_date}

{title}            ← deal items use related_deal_title
{description}      ← when present
👉 {action}        ← when ceo_action_suggestion present

— Made with Lumi · join free: https://runlumi.app/register?ref=<code>

Same header / footer i18n keys as the whole-brief share, so a recipient who sees both reads them as excerpts of the same product. Same F59 referral footer, so every per-item forward is an attributed signup doorway. Backend untouched. No new API call (the referral query was already loaded by the whole-brief share). No migration. No diagram change. No DRF / model / route surface touched.

One new locale key: action_share (en: “Share”, vi: “Chia sẻ”). Propagated to all 60 other locales via node scripts/propagate-new-i18n-keys.mjs — parity green (pnpm validate-i18n ✅).

Test plan: 8 new pure-helper cases in briefItemShare.test.ts (dated header · undated · title-only · description-only · action-only · title+description+action ordering · deal-title fallback · generic vs referral footer); 2 new component cases in BriefPage.test.tsx (per-item Share button on a decision → clipboard with focused single-item artifact, no whole-brief summary; per-item Share button on a risk → native share with single-item text, no clipboard fallback). All 22 existing BriefPage.test.tsx cases still pass; the whole-brief share path is behavior-preserving — only adding a sibling button, never modifying the existing hook or helper.

Local gates green before PR: tsc --noEmit 0 errors; pnpm test for affected files green (briefItemShare 8/8, BriefPage 22/22); node scripts/check-design-tokens.mjs clean (the new button uses shadcn <Button variant="ghost"> + Lucide Share2); pnpm validate-i18n parity green across all 62 locales.

2026-06-22 — Growth/distribution-object: stamp the sender’s organization name into the brief-share header

Type: Feature (growth / distribution-object identity — additive, no new surface)

  • The prior eleven runs converged the brief→referral spread loop end-to-end on the distribution mechanism: one-tap text share (#270), attributed referral footer (#287), reward-nudge awareness (#310), image card (#310), ?ref= register conversion (#321), the invitation magic link (#331), F35 transactional footers (#304/#306/#312/#315), the earned brief-ready / quiet-hours pushes (#308/#318/#320), and the Today share affordance (#350). What was untouched through all of that was the first thing a non-user receiver actually sees when a forwarded brief lands in their WhatsApp / Zalo / email: the header. It read Lumi Brief · {date} — anonymous. The receiver had zero signal whose brief this was.
  • Closed by stamping the sender’s organization name into the share artifact’s header — the Notion / Figma / Calendly distribution-object invariant (“every shared artifact carries its sender’s identity”). With the org name present the header becomes {org_name} · Lumi Brief · {date} (new keys share_header_with_org / share_header_with_org_nodate); without it (defensive — auth store still hydrating on a deep link), the existing share_header / share_header_nodate keys remain so sharing is never blocked. The shared useBriefShare hook reads organization?.name from the auth store and threads it through buildBriefShareText, so the BriefPage and TodayPage share affordances both stamp the identical header (one source of truth).
  • Frontend-only — no backend, no API, no migration, no route, no new dependency, no AI output, no design-token change → no registry scope change (Rule 6 clear; F12 + F02 both already ✅). Two new i18n keys propagated to all 62 locales via propagate-new-i18n-keys.mjs (parity green).
  • Growth metric this bets on: referral-attributed signups per shared brief (the downstream conversion the eleven prior runs opened). Hypothesis: an identifiable header converts at a higher rate than the anonymous Lumi Brief · … because the receiver knows the sender’s company before deciding whether to read past the first line — the same mechanism that made Notion / Figma / Calendly’s shared artifacts spread.
  • Tests: briefShare.test.ts (4 new cases — org name present dated, undated, empty / no-name fallback). BriefPage.test.tsx + TodayPage.test.tsx share suites stay green (auth store is not mocked → organization defaults to null → header falls back to the pre-change format that every existing assertion grep matches against).
  • See improvements ledger — brief-share org-name header.

2026-06-22 — Growth/discoverability: brief-share affordance now reachable from Today

Type: Feature (growth / distribution-object discoverability — additive, cross-surface)

  • The share viral loop (text share #270 + referral footer #287 + reward nudge #310 + image card #310 + ?ref= register conversion #321) was complete but invisible to a CEO who lives on Today — the highest-traffic CEO landing page. A CEO who never navigated to /intel/brief on a given day never forwarded.
  • Closed by surfacing the same Share action on F13 Today’s PageHeader.actions slot — executive-role-gated, brief-exists-gated, with identical share semantics to the BriefPage button.
  • Extracted the share orchestration from BriefPage.handleShare into a new frontend/src/hooks/useBriefShare.ts hook so both surfaces share one source of truth — the share artifact, fallback chain, toast keys, and AbortError handling can never drift. BriefPage itself is refactored to use the hook with no behavior change.
  • See F13 §13 2026-06-22 entry for the full surface change. No new artifact, no backend, no migration; F12 + F13 both already ✅.

2026-06-21 — Growth: make the viral loop fire — referral reward nudge on the share affordance

Type: Feature (growth / activation)

  • The prior two runs made the Brief a distribution object (one-tap Share) and embedded the CEO’s F59 referral link in its footer — a complete, attributed, rewarded viral loop. But the CEO had no way to know forwarding rewarded them: the reward lived only on the /settings/referrals page, never at the moment of sharing. A loop nobody knows fires won’t fire — awareness was the bottleneck on the whole prior investment.
  • Added a tasteful muted micro-caption under the Brief header action buttons (rendered only with a real brief): an inviting hint (share_reward_hint) when no friends have joined yet, switching to live progress (share_reward_progress, {{count}} joined) once times_used > 0. Driven by the already-fetched useReferralCode() data — no new request, backend, route, auth/tenancy/migration → no scope change (Rule 6 clear; F12 + F59 both already ✅).
  • Growth metric this bets on: referral activation — shares → attributed signups per active CEO/week. Surfacing the incentive at the point of action is the classic PLG lever that converts the loop the prior runs built.
  • New i18n keys (share_reward_hint, share_reward_progress) added to brief en+vi and propagated to all 62 locales (parity green).
  • Tests: BriefPage.test.tsx — reward hint renders with no joins; progress renders with times_used > 0.
  • Found (out of scope, logged for a future run): ReferralSettingsPage reads a non-existent code?.usage_count; the API returns times_used, so its progress bar always shows 0.

Type: Feature (growth / distribution loop)

Summary:

  • The one-tap Share (below) made the brief a distribution object, but its footer was a static, un-attributed constant — Made with Lumi · runlumi.app — every forward reached a non-user yet carried no doorway back (no attribution, no reward, no conversion signal). Closed the loop by embedding the CEO’s own F59 referral link in the footer of every shared brief: https://runlumi.app/register?ref=<code>, which grants the referrer and the new signup 1 month of premium (F59 spec). Each forwarded brief is now an attributed, rewarded signup doorway.
  • buildBriefShareText gained an optional referralUrl: present → new share_footer_referral key (— Made with Lumi · join free: {{url}}); absent (code still loading / request failed) → graceful fallback to the existing generic share_footer, so a transient referral-code failure never blocks sharing. BriefPage reads the code via the existing useReferralCode() hook and passes buildReferralUrl(code.code).
  • The https://runlumi.app/register?ref=<code> URL (previously duplicated in ReferralSettingsPage) was extracted to a single pure helper frontend/src/lib/referralLink.ts; that page now consumes it (drift removed).
  • No backend, API, or route change → no registry scope change (Rule 6 clear; F12 + F59 both already ✅). New i18n key (share_footer_referral) added to brief en+vi and propagated.
  • Growth metric this opens: registration referrals from forwarded briefs (attributed signups per active CEO/week) — the downstream conversion the prior shares-per-CEO proxy could not measure. See improvements ledger — brief footer F59 referral link.
  • Tests: briefShare.test.ts (footer with referral url + fallback without), referralLink.test.ts (url shape + empty/undefined guards), BriefPage.test.tsx (shared text contains the referral link).

2026-06-21 — Growth: the Brief becomes a distribution object (one-tap Share)

Type: Feature (growth / distribution)

Summary:

  • The CEO Brief is Lumi’s primary forwardable artifact, yet it had no way to leave the app — no share, export, or copy path. Added a Share brief button to BriefPage that builds a chat-pasteable, “Made with Lumi · runlumi.app”-branded text artifact from the brief on screen and either opens the native share sheet (navigator.share, mobile → Zalo/WhatsApp) or copies it to the clipboard (desktop, toast confirmation).
  • Pure artifact builder extracted to frontend/src/lib/briefShare.ts; section grouping extracted to frontend/src/lib/briefSections.ts and shared with the page (no behavior change to on-screen grouping). Dismissed items are excluded; confidence/ internal noise is stripped.
  • No backend, API, or route change → no registry scope change (Rule 6 clear). New i18n keys (share_*) added to brief en+vi and propagated to all locales.
  • Growth metric this opens: shares per active CEO/week (organic reach proxy). The footer URL is a single constant so a future run can swap in a per-CEO referral link (F59) to close the attribution + reward loop. See improvements ledger — brief becomes a distribution object.
  • Tests: briefShare.test.ts (artifact shape, dismissed-excluded, all-clear, no-date, deal-title fallback) + BriefPage.test.tsx (clipboard copy + toast, native share-sheet).

2026-06-18 — Visual QC: keep Brief refresh feedback inside the page

Type: UX Fix

Summary:

  • The generated Brief screenshot showed a success toast sitting over the top-right header controls, which made the first viewport feel cluttered right after the user asked for a refresh.
  • BriefPage.tsx now treats refresh as an inline state change instead of a separate success event: the page still pulses the refreshed timestamp, but it no longer stacks a toast over the Brief header and primary CTA.

Verification:

  • frontend/src/test/BriefPage.test.tsx
  • frontend/screenshots/04-brief/02-brief-after-generate.png

2026-06-16 — Deal brief items rendered in the web Brief (F50 U2, web)

Type: Feature/UI

  • BriefPage (frontend/src/pages/intel/BriefPage.tsx) now renders deal BriefItems in a new Pipeline section tier placed below decisions/risks/operations (mirrors the backend ranking so a deal never visually outranks a critical risk). Title prefers related_deal_title (falls back to item.title), links to /deals (the board — no per-deal route), TrendingUp icon matching the CRM nav; reuses the shared item card (priority border + ceo_action_suggestion). BriefItem TS type gained related_deal/related_deal_title. i18n brief.section_pipeline/brief.view_deal (en+vi + 60-locale seed, parity green). Suggestion text is server-localized, not hardcoded. Test in frontend/src/test/BriefPage.test.tsx. Detail in F50 §11 U2 / §13.

2026-06-16 — CRM deals as a Brief source (F50 U2, backend)

Type: Feature

  • BriefItem gained related_deal FK (crm.Deal, SET_NULL) and a deal item_type (migration briefs.0006). generate_morning_brief now surfaces operationally-stuck open deals — overdue scheduled activity (high) or no scheduled next activity (medium) — overdue-first, tenant-scoped, capped by remaining_slots(), confidence_score=0.7 (rule-based; related_deal is provenance, no EvidenceSource). BriefItemSerializer exposes related_deal + related_deal_title; brief queryset select_related("related_deal__company"). Tests backend/tests/test_brief_deals.py (14). Detail in F50 §11 U2 / §13.

2026-06-16 — LLM synthesis layer specified (summary + suggestions in CEO’s language)

Type: Spec Update (design for implementation)

2026-06-16 — evidence_sources exposed as rich nested objects on BriefItem

Type: API Change (serializer)

Summary:

  • Added BriefEvidenceSourceSerializer in briefs/serializers.py exposing id, source_type, title, and snippet (first 200 chars of content_text).
  • BriefItemSerializer.evidence_sources is now a SerializerMethodField returning nested objects. Falls back to related_risk.evidence_sources when the item has no evidence of its own.
  • Added related_risk__evidence_sources to _items_prefetch in views.py — no N+1.
  • Spec §5 updated with the response shape, fallback rule, performance note, and tenant-isolation claim.
  • Tests: TestBriefEvidenceSources (shape, truncation, fallback, empty, cross-tenant, list/today coverage, own-evidence-wins).

Reason:

  • Android Brief screen needs source_type, title, and snippet to display evidence inline — bare PK UUIDs are not enough (F30 §6.4, Six Rule 2).

Summary:

  • Added §4a: an LLM synthesis pass on top of the unchanged selection/ranking layer. Gemini/ DeepSeek (per F37) generate summary_text and each BriefItem.ceo_action_suggestion grounded strictly on the ranked items, in the CEO’s language. Mock mode uses member-language templates (replacing today’s hardcoded Vietnamese); provider failure falls back to templates so the brief always generates. One batched call for p95 < 5s.
  • §3 scope updated: LLM narration/suggestion in; LLM-driven item selection/ranking explicitly out (the query layer stays the grounded selector).

Reason:

  • §8/§11 row 8 flagged summary_text as generic (“Pulse processed. X items flagged.”) and the hardcoded Vietnamese ceo_action_suggestion strings violate F37 (must be in the CEO’s language). This closes both with a grounded, fail-safe synthesis layer.

Impact:

  • Behavior change to copy quality + language only; data model, item selection, evidence, and confidence untouched. Status flips when implemented + tested.

Tests: mock-mode template language (vi/en/th), real-mode JSON parse → summary + per-item suggestions (provider mocked), provider-failure fallback to templates, grounding (no invented items), latency single-call assertion.

2026-06-14 — Strategy v2 realignment (positioning)

Type: Documentation Change

Summary:

  • Stripped US-SaaS comparisons from the four §2 JTBD blocks (“In Notion or ClickUp, there is no brief generation”; “In Asana, a dashboard widget…”; “In ClickUp, delegating a risk means…”; “In Monday, closing an approval…”). The competitor is now framed as the owner scrolling Zalo + opening Excel + relying on memory.
  • §1: replaced “maybe open the CRM” / “skim Telegram groups” framing with the Zalo + Excel + memory ritual, and added a cross-link noting the Brief is the payoff of the F70 wedge — on first run assembled from the chaos the owner pasted, in <10 min, zero setup.

Reason:

  • Aligns F12 with competitive-strategy-v2.md and the MVP_SCOPE.md “Positioning doctrine (strategy v2, 2026-06-14)”: the competitor is Zalo/WhatsApp + Excel + owner memory, not US SaaS — and the Boss Brief is half the F70 wedge.

Impact:

  • Positioning only — no behavior, API, data-model, test, or status change. Brief generation, sections, ranking, inline actions, and lifecycle sync are untouched.

Tests: None — doc-only positioning edit.

Notes:

  • No behavior change is implied or required by this realignment.

2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)

Type: Documentation Change

Summary:

  • Numbered “Current Behavior” as §8 (was unnumbered between §7 and §8 Diagrams).
  • §8 Diagrams → §10; §9 Implementation Audit (narrative) → §11 (12-row table); §10 Edge Cases (23 items) absorbed into §11 note and git history; §11 Error States (4 tables from §11) → §9 (unified table ES1-ES10); §12 Open Questions → §12 (kept); §13 Bug Log stays.
  • §11 audit: 12 rows, 9 ✅, 3 ⚠️ gaps (transaction.atomic, can_act check, orphaned BriefItems).
  • §2 Updated: per-section independent failure (useQueries) confirmed fixed 2026-06-14.

2026-06-14 — Per-section independent failure (F13 useQueries fix)

Type: Bug Fix

Summary:

  • BriefPage sections now use useQueries — each section fails independently; other sections still load.
  • ES6 updated.

2026-06-13 — D-r26 re-verification: ACs 1–5 confirmed shipped

Type: Documentation Change

Summary:

  • All B-r32 ACs re-verified against repo: generate, idempotent, risk/decision sync, per-section failure.
  • Inline approve gap documented; transaction.atomic gap documented.

2026-06-12 — Edge Cases (23 items) and Error States sections added

Type: Documentation Change

2026-06-10 — B-r32: Brief generation shipped

Type: Feature

Summary:

  • POST /api/briefs/generate/ — creates today’s Brief with BriefItems from last 24h pulses.
  • §2 Risks (open high/critical), §3 Decisions (overdue first), §4 Commitments (overdue/at-risk), §5 Highlights, §1 Participation.
  • Inline approve endpoint added.
  • Risk/decision lifecycle sync (resolve/dismiss → BriefItem dismissed_at).

14. Success Metrics (Appendix)

  • Brief open rate > 80% on days with a pulse submission: the Brief has value only if the CEO reads it. Open rate is the primary engagement metric — a CEO who skips the Brief is not benefiting from the intelligence pipeline.
  • CEO action rate from Brief ≥ 40%: share of briefs where the CEO taps at least one action (decide, resolve a risk, view a commitment) within 2 hours of the brief being generated. A Brief the CEO reads but never acts on is a dashboards problem — it’s showing information but not enabling decisions.
  • Brief generation time < 5s p95: the CEO should not wait. A 10-second generation time breaks the morning habit. Measured from POST /api/briefs/generate/ to 200 response.
  • False-positive BriefItem rate < 20%: share of BriefItems where the CEO dismisses the item without acting. A dismissed item is an item that shouldn’t have been there. Above 20%, the CEO stops trusting the Brief.