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) withBriefItems 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 thebrief_generatethrottle (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”).
- Given
- 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_atstamped). - Given decide/cancel a decision → the linked BriefItem is immediately dismissed.
- Given the CEO taps “Cập nhật Brief” →
generateruns again; new/changed items appear; dismissed items stay dismissed.
- Given resolve/dismiss a risk → the linked BriefItem is immediately 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-itemceo_action_suggestionin 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:
- After items are built (before
bulk_create’s summary), assemble the grounded DATA block viaprompts.build_brief_prompt(items)— item id, type, title, key facts (owner, due, amount, severity), no invented content. - Call the provider through
language_check.check_and_retry(...)withbuild_system_prompt(member, "brief")(member = the brief’s CEO/owner) requesting strict JSON{"summary": ..., "suggestions": {"<item_id>": ...}}. - Write
brief.summary_textand eachBriefItem.ceo_action_suggestionfrom 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
LLMProviderErroror 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.
-
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. -
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. -
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_idfilter). - 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.
- Team/manager briefs — each manager gets a brief scoped to their team’s slice (same
generator,
-
Bounded, indexed retrieval (scale-check rails). Every brief query stays
LIMIT-bounded (nevercount()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. -
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
| Factor | Range | Source facts |
|---|---|---|
| Type base | 10000–20000 | item_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 |
| Severity | 0–8000 | severity — critical=8000, high=5000, medium=2000; Task priority — critical=3000, high=1500 |
| Decision urgency | 0–5000 | decision_needed_by <= brief_date → +5000 |
| Staleness | 0–3000 | created_at >3 days old → +min(age_days×200, 3000) |
| Overdue age | 0–3000 | due_date < brief_date → +min(overdue_days×200, 3000) |
| Customer exposure | 0–3000 | related_customer_name or customer keywords present |
| Cash exposure | 0–2000 | money_amount or cash keywords present |
Precedence guarantees (hardened by golden fixtures):
- 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.
- 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.
- 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.
- Equal-score stability: ties are resolved by
created_atISO 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_LIMITbackend/apps/ai/services/brief.py— integration in_generate_morning_brief_atomic(),_candidate_source_counts(),generation_metastorage
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
| Method | Path | Min role | Notes |
|---|---|---|---|
| POST | /api/briefs/generate/ | member | Re-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/ | member | List briefs for org (paginated) |
| GET | /api/briefs/<id>/ | member | Detail + items |
| POST | /api/approvals/<id>/approve/ | current approver | Inline 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
EvidenceChipwith ≥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 infrontend/src/components/page/EvidencePopover.tsx: clicking it opens a shadcnPopoverthat lazily fetchesGET /api/evidence-sources/<id>/(TanStack Query, one query per source id,enabledonly while open) and renders the source’s title, a 4-line-clamped snippet (excerpt, F86evidence_spanhighlighted 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 choiceFollowUpsPage.tsx’sSOURCE_ROUTEalready made);chat_note→ the real/hub/messages?channel=…&topic=…/?conversation=…thread, parsed from the legacypermalinkmetadata shape (frontend/src/lib/evidenceLinks.ts’sresolveChatPermalink— 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 othersource_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 atitletooltip 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-10pulse_entry_idfix entry below). Backend:apps/pulses/serializers.py’sEvidenceSourceDetailSerializer(built for iOSEvidenceDetailView, reused as-is) gainedevidence_span,pulse_entry_id, andpermalinkfields 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 briefbutton 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 helperfrontend/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 callingnavigator.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-callInvalidStateErrorwithout 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 Briefwhen undated) — keysshare_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 originalLumi Brief · {brief_date}/Lumi Brief(keysshare_header/share_header_nodate) so sharing is never blocked. The org name is read once by the shareduseBriefSharehook 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_atexcluded) — title (deal items userelated_deal_title) plus itsceo_action_suggestionordescription. 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 helperfrontend/src/lib/referralLink.ts(buildReferralUrl), also used by/settings/referrals.
- 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:
- 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_used0 / code still loading) → inviting hintshare_reward_hint: “Forward your brief — you and every friend who joins get a month of Lumi free.” - Friends joined (
times_used > 0) → progressshare_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.
- No friends joined yet (
- 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 helperfrontend/src/lib/briefShareCard.ts, rasterised to PNG by the thin browser-onlyfrontend/src/lib/briefImage.tsvia<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’ssummary_textas the headline (orcard_headline_fallbackwhen blank); acard_prioritiessection listing the top items by the sharedgroupBriefItemsordering (decisions/critical first), live items only (dismissed_atexcluded), capped with acard_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.nameis 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 howreferralUrlis passed verbatim throughBriefShareCardOptions). - Doorway footer:
card_footer_referral(“Made with Lumi · Join free → {url}”) carrying the CEO’s F59 referral link when available (samebuildReferralUrlhelper as the text share), falling back to the genericcard_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).
- A 1080×1350 portrait SVG on warm Lumi paper, brand palette inlined (the card renders outside
the app CSS, so
- 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
BriefItemCardtherefore renders aShare2ghost button in its action row (alongsideAssign/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 (sameshare_copied/share_errortoasts, sameAbortErrorcancel path). Artifact shape (pure helperfrontend/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.nameis available from the auth store:{org_name} · Lumi Brief · {brief_date}(or{org_name} · Lumi Briefwhen undated) — keysshare_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 originalLumi Brief · {brief_date}/Lumi Brief(keysshare_header/share_header_nodate) so sharing is never blocked. The org name is read once by the shareduseBriefItemSharehook from the sameuseAuthStoreselector asuseBriefShare, 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
descriptionon 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_footerkeys 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).
- 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
- i18n:
brief(addsshare_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
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Generation: risks, decisions, commitments, highlights included | backend | TestBrief::test_generate_brief (tests/api/test_briefs.py ~19) |
| 2 | Same-day re-generation replaces BriefItems (delete+recreate) | backend | TestBrief::test_generate_brief_replaces_same_day_brief (tests/api/test_briefs.py ~46) |
| 3 | Cross-tenant BriefItem isolation | backend | TestBrief::test_brief_tenant_isolation (tests/api/test_briefs.py ~37) |
| 4 | Dismiss → BriefItem dismissed_at stamped | backend | TestBriefItemActions::test_dismiss_sets_dismissed_and_feedback (tests/api/test_briefs.py ~264) |
| 5 | Risk resolve/dismiss → Brief sync | backend | TestAC3_StatusChangeLeavesBrief::test_resolve_dismisses_existing_brief_items (tests/test_f10_risks_r32.py ~218) |
| 6 | Generate + view + action flow | e2e | describe("Brief") |
| 7 | Share artifact: header, summary, sections, branded footer | frontend | briefShare.test.ts |
| 8 | Share artifact: dismissed items excluded; all-clear; no-date; deal-title fallback | frontend | briefShare.test.ts |
| 9 | Share button copies to clipboard (no navigator.share) + success toast | frontend | BriefPage.test.tsx |
| 10 | Share button uses native share sheet when navigator.share exists | frontend | BriefPage.test.tsx |
| 11 | Share sheet cancelled (AbortError) → no clipboard fallback, no toast | frontend | BriefPage.test.tsx |
| 12 | Native share non-cancel failure → retryable error with no clipboard fallback; clipboard is used only when native share is unavailable, and clipboard failure surfaces an error | frontend | BriefPage.share.test.tsx · shareSemantics.test.ts |
| 13 | Referral reward hint renders by the share action when no friends have joined yet | frontend | BriefPage.test.tsx |
| 14 | Referral progress ({{count}} joined) renders once times_used > 0 | frontend | BriefPage.test.tsx |
| 15 | Share card SVG: valid XML; eyebrow + date; summary headline (+ fallback when blank); stakes ordering (decisions before operations); dismissed excluded; all-clear state | frontend | briefShareCard.test.ts |
| 16 | Share card SVG: referral doorway footer when url given vs generic footer when absent; XML-escapes special chars; “+N more” overflow line past the cap | frontend | briefShareCard.test.ts |
| 17 | Image action uses native file share when supported; native rejection never downloads; unsupported browsers receive an explicitly labelled Download image fallback | frontend + e2e | shareSemantics.test.ts · BriefPage.share.test.tsx · describe("Brief") |
| 18 | Per-item share artifact: dated/undated header, title (deal-title fallback), description, 👉 action_suggestion line ordering, generic vs referral footer | frontend | briefItemShare.test.ts |
| 18a | Per-item share artifact header stamps the sender’s organization name when present ({org} · Lumi Brief · {date} / undated variant) | frontend | briefItemShare.test.ts |
| 18b | Per-item share artifact header falls back to plain Lumi Brief · {date} when org name is missing / empty / whitespace / undefined (defensive — never " · Lumi Brief") | frontend | briefItemShare.test.ts |
| 18c | Per-item share artifact carries both org-name stamp and F59 referral footer together — identity and growth doorway co-exist on the same forwarded artifact | frontend | briefItemShare.test.ts |
| 19 | Per-item Share button on each BriefItemCard → native share sheet (when navigator.share exists) / clipboard fallback with single-item text, not the whole-brief artifact | frontend | BriefPage.test.tsx |
| 20 | Share artifact header stamps the sender’s organization name when present ({org} · Lumi Brief · {date}) | frontend | briefShare.test.ts |
| 21 | Share artifact header falls back to plain Lumi Brief · {date} when org name is missing / empty / undefined (defensive — never " · Lumi Brief") | frontend | briefShare.test.ts |
| 22 | Share 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 preserved | frontend | briefShareCard.test.ts |
| 23 | Share 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>) | frontend | briefShareCard.test.ts |
| 24 | A 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 settles | frontend | shareSemantics.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
| # | Error | Response | Notes |
|---|---|---|---|
| ES1 | Cross-tenant brief access | 404 | TestBrief::test_brief_tenant_isolation (tests/api/test_briefs.py ~37) |
| ES2 | Generate with no pulse submissions yesterday | 200 — Brief generated with empty sections + context note | No error; empty state has placeholder copy |
| ES3 | Generate mid-crash (partial Brief) | no partial Brief persists | generate_morning_brief now runs inside transaction.atomic(); a failed generation does not leave a half-written Brief behind |
| ES4 | Re-generation on same day | Replaces 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) |
| ES5 | Inline approve shown to non-actionable user | CTA hidden in Brief UI; direct approval API call still returns 403 | Brief uses approval_can_act for affordance gating; F26 remains the server-side authorization gate |
| ES6 | Frontend section load failure | Per-section useQueries failure shows section error state; other sections still load (fixed 2026-06-14) | test_brief_section_independent_failure |
| ES7 | Dismiss already-dismissed BriefItem | 200 — idempotent (re-stamps dismissed_at) | test_dismiss_briefitem_idempotent |
| ES8 | Source Risk/Decision deleted after appearing in Brief | linked BriefItem auto-dismisses before FK nulls | apps/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 |
| ES9 | generate called by viewer (non-member) | 403 | Role gate on generate endpoint |
| ES10 | No organization on request | 400 (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)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | POST /api/briefs/generate/ — creates or replaces today’s Brief, returns with all BriefItems | ✅ match | B-r32; TestBrief::test_generate_brief (tests/api/test_briefs.py ~19) |
| 2 | Same-day re-generation replaces BriefItems (delete+recreate); 10/hour throttle prevents abuse | ✅ match | TestBrief::test_generate_brief_replaces_same_day_brief (tests/api/test_briefs.py ~46) |
| 3 | §2 Risks: open high/critical, ordered by severity | ✅ match | generate_morning_brief filters severity__in=["high","critical"], status="open"; P1 (F10) resolved 2026-06-14 |
| 4 | §3 Decisions: overdue first, critical-urgency badge | ✅ match | decision_needed_by ≤ today → priority="critical"; ordering by decision_needed_by |
| 5 | §4 Commitments: overdue/at-risk (not all) | ✅ match | Commitment Brief filter uses due_date__lte=today; test_overdue_commitment_in_brief |
| 6 | Risk resolve/dismiss → BriefItem dismissed_at stamped | ✅ match | P2 (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 |
| 7 | Decision decide/cancel → BriefItem dismissed_at stamped | ✅ match | 2026-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 |
| 8 | summary_text is generic in mock-extraction mode | ✅ match (accepted limitation) | “Pulse processed. X items flagged.” until LLM-mode F08; test_generate_returns_summary_text |
| 9 | Generation not wrapped in transaction.atomic() | ✅ fixed | generate_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) |
| 10 | Inline approval from Brief only appears for actionable approval items and reuses F26 authorization | ✅ match | 2026-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) |
| 11 | Frontend per-section independent failure (useQueries) | ✅ match | Fixed 2026-06-14; test_brief_section_independent_failure; each section shows own error state |
| 12 | BriefItem linked to deleted Risk/Decision no longer stays in the active Brief queue | ✅ fixed | 2026-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 |
| 13 | Visual QC: refreshing the Brief does not throw a success toast over the page header and primary CTA | ✅ match | 2026-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_textquality: 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 sentinelstatus: "not_generated"when no morning Brief row exists. This value is not part of persistedBrief.STATUS_CHOICES, but it is part of the response contract decoded byBriefResponse.- Shared Android/iOS presentation now maps both
not_generatedand persisteddraftto the localized no-brief explanation with a Generate action.generated,reviewed, andsentremain 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.tsasserts the fullhttps://runlumi.app/register?ref=...URL is present without an ellipsis.
2026-07-10 — W-AIQ-05: evidence chips resolve everywhere — popover with source snippet + open-source link (read side)
Type: Feature (P0 — “evidence on every AI output” rail, read side)
Summary:
- Follows the same-day
pulse_entry_idfix below. That fix made the backend data honest; this closes the read side the original walk also found:frontend/src/components/page/EvidenceChip.tsxrendered 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: wrapsEvidenceChipin a shadcnPopovertrigger whenever the caller passes ≥1 real evidence-source id. Opening it lazily fetches each id viaGET /api/evidence-sources/<id>/(new hookfrontend/src/hooks/queries/useEvidenceSourcesDetail— TanStack QueryuseQueries,enabledonly while open, 5-minutestaleTime) and renders title + a 4-line-clamped snippet (highlighting the F86evidence_spanverbatim substring inside it, when present) + an “Open source” link — but only whenfrontend/src/lib/evidenceLinks.ts’sresolveEvidenceSourceLinkcan 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 choiceFollowUpsPage.tsx’sSOURCE_ROUTEmade before this change);chat_note→resolveChatPermalink()parses themetadata_json.permalinklegacy path shape (/messages/channels/<id>?topic=…//messages/conversations/<id>, stamped byapps/messaging/services.py’sflag_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 atitletooltip (EvidenceChipgained an optionaltitlepassthrough prop) explaining there’s nothing to open, instead of silently doing nothing on click/hover. - Backend (
apps/pulses/serializers.py):EvidenceSourceDetailSerializer(built for iOSEvidenceDetailView—GET /api/evidence-sources/<id>/and its/api/pulses/evidence-sources/<id>/router twin, reused as-is rather than adding a second endpoint) gainedevidence_span,pulse_entry_id, andpermalinkfields. Needed because Risks/Commitments/Decisions/Today evidence chips only ever carry a bareEvidenceSourceid (evidence_sources: string[]) — unlike Brief’s richer embeddedEvidenceSourceBrief[]shape, which already hadpulse_entry_id— so without this addition only Brief’s popover could ever resolve a destination. 5 new backend tests intests/api/test_ai_infra.py::TestEvidenceSourceDetail. - Wired into all 6 consumers:
BriefPage.tsx(replaces the one-offpulse_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), andFollowUpsPage.tsx(interactive only once a follow-up has been replied to —item.reply_evidence_sourceis the only realEvidenceSourceid aFollowUpRequestever 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’sPAGESlist 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/todaya 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 newbutton[data-slot="evidence-chip-trigger"]popover-trigger pattern alongside plain<a>links, so it can honestly count both. Re-run results appended todocs/tests/evidence/web/W-AIQ-05/walk.mdas 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(newtitle-passthrough case).i18n/locales/en|vi/evidence.jsonnew namespace (propagated to all 61 locales viapropagate-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.mdfound 0 of 71 evidence chips clickable across Brief/Risks/Commitments/Decisions/Follow-ups/Today in production-shaped data.BriefPage.tsxis the only page wired to turn a chip into a<Link>to/intel/pulse?highlight=<id>, and it only does so whenevidence_sources[].pulse_entry_idis present. - Root cause:
apps/pulses/views.py’ssubmit_pulse— the primary, real-world pulse submission path — created thesource_type="pulse_entry"EvidenceSourcewithmetadata_json=pulse.answers_json, clobbering the{"pulse_entry_id": ...}shapeBriefEvidenceSourceSerializer.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 rowsubmit_pulsehad just created, so the correctly-tagged create path was rarely reached. - Fix:
metadata_jsonnow carries both the linkage and the original payload —{"pulse_entry_id": str(pulse.id), "answers": pulse.answers_json}— in every pulse_entryEvidenceSourcecreation path:apps/pulses/views.py(submit_pulse),apps/telegram/handlers.py(handle_pulse_reply, same bug), andapps/accounts/management/commands/seed_demo_lumi.py(both evidence-creation blocks — the second one hadmetadata_jsonunset entirely). - Same-bug sweep: grepped every other
EvidenceSource.objects.createsite (chat_note, uploaded_file, manual_note, email, ext_confirmation, recurring_task, task) and every reader ofEvidenceSource.metadata_json— no other source_type currently has a serializer reading an id key back out ofmetadata_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 missingpulse_entry_idby matching(organization, created_by)+ (content_text == pulse.raw_textor the legacy-bugmetadata_json == pulse.answers_jsonshape); 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, nocreated_by, cross-tenant safety).
Impact:
EvidenceSource.metadata_jsonshape change forsource_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
<1hbrief 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_generatethrottle (10/hour, confirmed inbackend/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
<1hidempotency acceptance criterion (“Given the Brief is already current (generated < 1h ago), thengeneratereturns the existing Brief”). This AC was never implemented.generate_morning_brief()inbackend/apps/ai/services.pyalways calls_generate_morning_brief_atomic(), which unconditionally deletes existing BriefItems and rebuilds from live state. Abuse is bounded by thebrief_generateDjango REST Framework throttle at 10/hour (set inbackend/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 fromIdempotent within 1h; returns Brief with all BriefItemsto reflect delete+recreate semantics. - §7 rows 1-5: stale test class
TestBriefGenerationnever 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)
- Row 1: →
- §9 ES1: test name corrected to
TestBrief::test_brief_tenant_isolation. - §9 ES4: entry renamed from
Re-generation within 1h / Returns existing Brief (idempotent)toRe-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.pyso 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_NULLhistory 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=shortcd 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_acttoBriefItemSerializerso Brief payloads carry whether the current member is the actionable approver for a linked approval item. - Updated
BriefPage.tsxto render the inline Approve CTA only whenapproval_can_actis true, while still routing the action through the existing F26 approvals API. - Added backend proof in
backend/tests/test_approvals.pyand frontend proof infrontend/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=shortcd 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
EvidenceSourcerows from the field event/worklog proof and also setrelated_taskto 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_briefdelegates to_generate_morning_brief_atomicinsidetransaction.atomic()—brief.items.delete()+bulk_create+summary_textsave are a single atomic unit; no partial-brief state on crash.
Brief routing keyword expansion (_generate_morning_brief_atomic):
| List | Additions | Reasoning |
|---|---|---|
_CASH_KW | hết tiền, không có tiền, vốn hết, bị phạt, phạt tiền, tiền phạt | Operational cash dry-up and financial penalties are cash-section signals, not generic risks |
_BLOCKED_KW | cách ly, bị cách ly, bị thương, nhập viện, quá tải, không đủ nhân sự, thiếu người | Workforce unavailability and capacity overload → surfaces as unblock action in brief |
_COMPLAINT_KW | hủy, hủy đơn, khách hủy | Order cancellations are a customer complaint signal, not generic operations |
_MISSING_KW | chờ 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 (
buildBriefItemShareTextviauseBriefItemShare, on everyBriefItemCardShare button) — still emitted the anonymousLumi 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
orgNamefield tobuildBriefItemShareTextinfrontend/src/lib/briefItemShare.ts. When present and trimmed non-empty, line 1 becomes{org_name} · Lumi Brief · {brief_date}(or{org_name} · Lumi Briefundated) via the existingshare_header_with_org/share_header_with_org_nodatekeys — 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.useBriefItemSharereadsuseAuthStore((s) => s.organization?.name)(the same selector asuseBriefShare), 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 justMorning Brief 2026-06-22with no sender — the channel where Lumi spreads most was the channel where the sender-identity gap was widest. - Closed by adding an optional
orgNamefield toBriefShareCardOptionsinfrontend/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-awaretruncatehelper 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’shandleShareImagereadsorganization?.namefromuseAuthStore(mirroringuseBriefShare’s pattern from #392) and threads it asorgNamethroughbuildBriefShareCardSvg(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
referralUrlis passed verbatim throughBriefShareCardOptions). 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 readLumi 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 keysshare_header_with_org/share_header_with_org_nodate); without it (defensive — auth store still hydrating on a deep link), the existingshare_header/share_header_nodatekeys remain so sharing is never blocked. The shareduseBriefSharehook readsorganization?.namefrom the auth store and threads it throughbuildBriefShareText, 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.tsxshare suites stay green (auth store is not mocked →organizationdefaults tonull→ 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/briefon a given day never forwarded. - Closed by surfacing the same Share action on F13 Today’s
PageHeader.actionsslot — executive-role-gated, brief-exists-gated, with identical share semantics to the BriefPage button. - Extracted the share orchestration from
BriefPage.handleShareinto a newfrontend/src/hooks/useBriefShare.tshook so both surfaces share one source of truth — the share artifact, fallback chain, toast keys, andAbortErrorhandling can never drift.BriefPageitself 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/referralspage, 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) oncetimes_used > 0. Driven by the already-fetcheduseReferralCode()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 tobriefen+vi and propagated to all 62 locales (parity green). - Tests:
BriefPage.test.tsx— reward hint renders with no joins; progress renders withtimes_used > 0. - Found (out of scope, logged for a future run):
ReferralSettingsPagereads a non-existentcode?.usage_count; the API returnstimes_used, so its progress bar always shows 0.
2026-06-21 — Growth: close the viral loop — the shared brief carries the CEO’s F59 referral link
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. buildBriefShareTextgained an optionalreferralUrl: present → newshare_footer_referralkey (— Made with Lumi · join free: {{url}}); absent (code still loading / request failed) → graceful fallback to the existing genericshare_footer, so a transient referral-code failure never blocks sharing.BriefPagereads the code via the existinguseReferralCode()hook and passesbuildReferralUrl(code.code).- The
https://runlumi.app/register?ref=<code>URL (previously duplicated inReferralSettingsPage) was extracted to a single pure helperfrontend/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 tobriefen+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 briefbutton toBriefPagethat 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 tofrontend/src/lib/briefSections.tsand 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 tobriefen+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.tsxnow 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.tsxfrontend/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 rendersdealBriefItems 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 prefersrelated_deal_title(falls back toitem.title), links to/deals(the board — no per-deal route),TrendingUpicon matching the CRM nav; reuses the shared item card (priority border +ceo_action_suggestion).BriefItemTS type gainedrelated_deal/related_deal_title. i18nbrief.section_pipeline/brief.view_deal(en+vi + 60-locale seed, parity green). Suggestion text is server-localized, not hardcoded. Test infrontend/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
BriefItemgainedrelated_dealFK (crm.Deal, SET_NULL) and adealitem_type(migrationbriefs.0006).generate_morning_briefnow surfaces operationally-stuck open deals — overdue scheduled activity (high) or no scheduled next activity (medium) — overdue-first, tenant-scoped, capped byremaining_slots(),confidence_score=0.7(rule-based;related_dealis provenance, no EvidenceSource).BriefItemSerializerexposesrelated_deal+related_deal_title; brief querysetselect_related("related_deal__company"). Testsbackend/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
BriefEvidenceSourceSerializerinbriefs/serializers.pyexposingid,source_type,title, andsnippet(first 200 chars ofcontent_text). BriefItemSerializer.evidence_sourcesis now aSerializerMethodFieldreturning nested objects. Falls back torelated_risk.evidence_sourceswhen the item has no evidence of its own.- Added
related_risk__evidence_sourcesto_items_prefetchinviews.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, andsnippetto 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_textand eachBriefItem.ceo_action_suggestiongrounded 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_textas generic (“Pulse processed. X items flagged.”) and the hardcoded Vietnameseceo_action_suggestionstrings 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.