Spec: F10 — Risks
Status: shipped · Audited: 2026-06-10
1. Problem & CEO Job
See clearly. A customer gone quiet, receivable aging, or slipping delivery is usually visible to someone days before the CEO sees it. Risks turn that front-line awareness into executive awareness. Vietnamese-first framing matters: “khách nợ lâu” is a relationship risk before a finance metric, and the taxonomy matches how VN CEOs triage (customer, revenue, cash, delivery, people, hiring, legal, operations).
What we replace: nothing — risk lives in the boss’s gut. The interior fit-out contractor has no risk register or software to maintain one; risk sits in the boss’s head, a half-remembered Zalo message, or an aging công nợ spreadsheet. Chậm nghiệm thu (acceptance slipping), công nợ khách (a client receivable aging), and trễ giao vật tư (a material delivery running late) are often visible on site days before the owner hears them, then disappear in chat. Lumi extracts “có rủi ro,” “khách chưa nghiệm thu,” or “vật tư chưa về” from a pulse into a typed, severity-coded, evidenced CEO-queue risk without extra work. For Vietnamese SMEs with no operations function or risk tool, that zero-setup queue makes risk review a daily habit and becomes the company’s first risk register.
2. User Stories & Acceptance Criteria
- JTBD (member): “When I answer the daily pulse and mention ‘khách A chưa phản hồi 2 tuần,’ I need that to register as a customer risk automatically — so I’m not choosing between raising a flag (which feels confrontational) and staying quiet (which lets the fire grow). There is no risk board to open and no form to submit — the alternative is a Zalo message the boss may scroll past. Lumi lets me raise a flag in one sentence with zero extra steps.”
- Given AI extraction, then risk carries evidence,
ai_reasoning_summary(why Lumi flagged it), honest confidence. - Given the pulse mentions a customer by name, then
related_customer_nameis populated; if the name matches a known Company,companyFK is set too.
- Given AI extraction, then risk carries evidence,
- JTBD (CEO): “When I open the risk list at 7am, I need risks ranked by severity with critical items impossible to miss — so I catch a brewing disaster before it’s too late. Without Lumi the only “register” is my own memory and a scattered Zalo history — nothing ranks công nợ khách above a minor delay, and nothing surfaces the slow burns at all. Lumi surfaces critical/high risks first with severity color-coding and zero configuration.”
- Given the list, default order surfaces open critical/high before everything else; severity is color-coded with the app-wide palette. (A-r26:
RiskViewSet.get_querysetannotatesseverity_rankand orders by-severity_rank, -created_at) - Given severity high/critical and status open, then the risk appears in Brief §2 (F12) with urgency context.
- Given a risk with money exposure (
money_amountnon-null), then the amount is visible in the list row — no need to open the detail to understand financial impact.
- Given the list, default order surfaces open critical/high before everything else; severity is color-coded with the app-wide palette. (A-r26:
- JTBD (CEO): “When a risk turns out to be noise or resolves on its own, I need to dismiss or resolve it in one click — so the queue stays honest and I can trust that ‘open’ means ‘real fire, still burning.’ A worry tracked in someone’s head or a Zalo thread never gets formally closed — it just fades and resurfaces. Lumi makes resolving or dismissing it one tap from the list.”
- Given status → resolved/dismissed, then it leaves the active Brief immediately: the existing BriefItem is marked dismissed and the open card cannot remain cached;
resolved_atstamped on resolve. - Given dismiss,
resolved_atis NOT set (asymmetric by design: dismissed ≠ resolved — see §11 E8). - Given several active risks are noise, a non-viewer can select and dismiss them together after explicit confirmation; the server applies all rows or none, caps the request at 100 UUIDs, and dismisses linked BriefItems in the same transaction.
- Given status → resolved/dismissed, then it leaves the active Brief immediately: the existing BriefItem is marked dismissed and the open card cannot remain cached;
- JTBD (manager): “When I discover a risk outside of pulse — in a meeting, a Zalo DM, a customer call — I need to record it with type, severity, and money exposure right away, so the CEO’s risk picture stays complete even when the AI misses something.”
- Given a manual create with only title and risk_type, then it saves with default
mediumseverity andmediumlikelihood. - Given money amount and currency filled, then the amount is highlighted in the CEO’s list view with exposure context.
- Given a manual create with only title and risk_type, then it saves with default
- JTBD (CEO): “When I see a risk flagged in the Brief, I need to understand the situation and assign someone to handle it without opening a separate list — so I can triage a brewing disaster in 30 seconds flat. The usual alternative is forwarding a Zalo message to the right person and hoping they act, with no record that it was assigned. Lumi lets me assess the evidence and delegate from the Brief card itself — one tap to see the detail, one tap to assign a mitigation task. The chain: Brief tap → risk detail → assign → back to Brief in under a minute.”
- Given the risk appears in the Brief, then the BriefItem shows severity badge, evidence summary, and a one-tap “Giao việc” action (F25).
- Given the CEO assigns a mitigation, then the created Task carries
related_riskFK, severity context in description, and evidence source links — the assignee sees the full picture without a separate briefing.
- JTBD (CEO): “When I’ve delegated risk mitigation to someone, I need to see which risks have no linked task or commitment — so I know which fires I’ve assigned someone to fight and which are still sitting unattended. With no software at all, a risk and the work to fix it have no connection — I rely on memory and on the teammate to remember the Zalo message. Lumi surfaces the percentage of open high/critical risks with no linked mitigation, so I never mistake ‘I’ve seen it’ for ‘it’s being handled.’”
- Given a risk severity high/critical and status open, then
related_taskandrelated_commitmentnulls are visible in the risk list row or a summary count in the header. - Given a risk gains a linked task/commitment, then the list row updates to show the link; the header “no mitigation” count decreases.
- Given a risk severity high/critical and status open, then
3. Scope Boundaries
- In: CRUD, AI extraction, typing/severity/likelihood, links to task/commitment/customer-name, status lifecycle, money fields.
- Out: risk matrices/heatmaps (BI theater), mitigation workflows, probability math. One list, ranked, actionable.
4. Data Model
operations.Risk — risk_type (9 values), severity low→critical, likelihood low/med/high, status: open → monitoring → resolved | dismissed. ai_reasoning_summary, confidence_score, evidence_sources, optional related_task/related_commitment/related_customer_name, money fields.
5. API Contract
| Method | Path | Min role | Notes |
|---|---|---|---|
| CRUD | /api/risks/ | member (write) / viewer (read) | Router ViewSet, tenant-scoped |
| POST | /api/risks/bulk-dismiss/ | member | 1–100 unique UUIDs; active rows only; tenant-scoped and atomic |
6. UI / UX
/intel/risks(RisksPage.tsx). Severity drives visual hierarchy — a critical risk must be findable in <1s (“5am test”).- Dense risk metadata is grouped into two calm lanes: business exposure (customer and money) and mitigation state. On wider viewports the mitigation lane has a subtle divider; on mobile both lanes wrap without truncating values or creating nested cards.
- Non-viewers can enter a focused bulk-triage mode backed by the shared TanStack
DataTable, select only active risks, and dismiss the selection through a destructive confirmation. Viewers see the same evidence-rich list without mutation affordances. Individual assistantdismiss-riskactions remain the conversational path and can be proposed together; bulk mode adds no required form. - AI rows expose
ai_reasoning_summary— Lumi explains itself in one line. - States: empty is a positive state (“Không có rủi ro đang mở 🎉” — silence is signal), loading skeletons.
- Per-risk Share on every open Risk card on Today (growth / atomic distribution-object). Each open Risk card in the F13 Today Risks section renders a ghost-variant
Share2icon button alongside the card content. Clicking it builds a focused single-risk artifact via the newfrontend/src/hooks/useRiskShare.tshook — same fallback chain, sameAbortErrorno-fallback, sameshare_copied/share_errortoasts from thebriefnamespace as the whole-brief, per-item-brief, and per-decision shares, so the four share surfaces can never drift. The artifact shape is risk-specific: an identity-stamp header that reads{org} · Lumi · Risk · {date}when the sender’s organization name is available (the Notion / Figma / Calendly distribution-object invariant — mirroring the whole-brief share #392, the share-card image #437, the per-item brief share #442, and the per-commitment share #448 so every CEO-forwarded artifact carries identifiable letterhead instead of an anonymous Lumi header) and falls back toLumi · Risk · {date}when missing / whitespace / hydrating (sharing is never blocked), risk title,⚠ Severity: {severity}line (always rendered — severity is a property of every risk),🏷 Type: {risk_type}line (when populated), riskdescriptionline (when populated), and the same generic-or-referral footer (”— Made with Lumi · join free: {url}”) embedded with the CEO’s F59 referral link viabuildReferralUrl(referralCode?.code). Rendered only for open risks (r.status === 'open') — resolved/dismissed risks are not forwarded. The button stays inside the executive-gated Risks section, so members never see it (matches the existing role gate on the section itself). This extends the Notion-/Calendly-/Figma-class atomic-share distribution-object pattern that landed on per-decision (#398) to the second-most-natural CEO forward gesture on the same highest-traffic landing surface: the CEO’s triage-forward gesture (“@head_of_finance, this is yours”) becomes a one-tap branded, attributed doorway instead of a screenshot or retyped Zalo message. - Per-risk Share on the deeper
/intel/riskstriage queue (growth / triage-surface throughput). The same ghost-variantShare2icon button also renders on every open Risk card onRisksPage.tsx— the page CEOs walk through every open and monitoring risk on during morning triage. SameuseRiskSharehook, same artifact, same fallback chain, samer.status === 'open'gate, same aria-label shape (${share_button}: ${title}), sametodayIso = new Date().toISOString().slice(0, 10)so an artifact forwarded from either surface lands identically in a Zalo thread. The button sits at the head of the action cluster (Share · Resolve · Dismiss). Today catches the brief-surfaced risks; the triage queue catches the ones beneath that — same gesture, where the gesture happens. - Per-risk Share-image affordance on every open Risk card on both Today and
/intel/risks(growth · per-item visual identity-stamp arc, second leg). SiblingImageDownghost-variant icon button rendered next to the textShare2button on every open Risk card. Wired to the newfrontend/src/hooks/useRiskShareImage.tshook, which builds a 1080×1080 branded PNG card viafrontend/src/lib/riskShareCard.ts(a pure SVG builder that mirrorsdecisionShareCard.ts1:1 — same palette, identity-stamp position, eyebrow shape, doorway-footer band — with a severity-coloured accent rail on the left edge of the white sheet that matches the in-appborder-l-4 border-l-[var(--color-risk-red|signal-amber|lumi-blue|soft-slate)]rail on the corresponding Risk row, and a severity-coloured severity line so the receiver reads priority from the thumbnail). Card shape: identity letterhead at top-left ({org}when present),RISKeyebrow with brand mark and today’s date, risk title as headline (wrap up to 4 lines × 30 chars, navy weight 700 size 50), severity line in the accent colour, type line, optional description excerpt (up to 2 lines), F59-referral doorway-footer band. Rasterises via the existingsvgToPngBlobfrombriefImage.ts(zero new dependency), then runs the same Web Share API ({ files, text }) → download fallback chainuseDecisionShareImagealready uses.buildRiskShareTextrides along as the share-sheettextpayload so a recipient with image previews disabled still gets readable context. Falls back to a PNG download with therisks.card_share_image_savedtoast on desktop;share_image_errortoast (reused frombriefnamespace) on failure. Disabled while saving. Samer.status === 'open'gate as the text-share button. Opens the second leg of the per-item visual identity-stamp arc (decisions ✅ #472, risks ⏳ this PR, commitments ⏳ Next ①). In Lumi’s image-first wedge markets (Zalo / WhatsApp / Messenger / Viber) screenshots forward where pasted text doesn’t. - i18n:
risks(addsshare_button,share_header,share_header_nodate,share_severity,share_type— five risk-specific keys; plusshare_header_with_organdshare_header_with_org_nodate— two identity-stamp variants used when the sender’s organization name is present, mirroring thebriefandcommitmentsnamespaces’_with_orgkeys from PR #392 and PR #448; pluscard_eyebrow(“RISK”) andcard_share_image_savedadded 2026-06-23 for the per-risk share-card image). The per-risk share artifact reuses thebriefnamespace’s footer (share_footer,share_footer_referral), toast (share_copied,share_error), and image-affordance keys (share_image_button,share_image_error,card_footer,card_footer_referral) — same copy as every other share surface, so one copy edit lands on all four shares. - Evidence chip resolves (W-AIQ-05 read side). The
EvidenceChipin the risk row’s trust cluster is wrapped infrontend/src/components/page/EvidencePopover.tsx— clicking a chip with ≥1 evidence-source id opens a popover with the source’s title, snippet, and (when the source resolves to a real destination) an “Open source” link.r.evidence_sourcesalready carries realEvidenceSourceids, passed straight through. Full design shared with Brief/Commitments/Decisions/Today/Follow-ups — see F12 brief §6 and §13’s 2026-07-10 entry.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | CRUD, lifecycle, cross-tenant blocked | backend | tests/test_api.py::TestRisk |
| 2 | AI extraction with reasoning + evidence | backend | tests/test_api.py::TestAIExtraction |
| 3 | High/critical feeds brief, resolved leaves it | backend | tests/test_api.py::TestBriefGeneration |
| 4 | List + create flow | e2e | describe("Risks") |
| 5 | Per-risk share artifact builder: dated/undated header, title, severity line, risk-type line, description line, full ordering, generic vs referral footer, identity-stamp header (org name present dated/undated), defensive whitespace fallback to anonymous header, org name + referral footer co-exist (identity & growth doorway hold together) | frontend | riskShare.test.ts (12 deterministic cases — 8 original + 4 identity-stamp) |
| 6 | Per-risk Share button renders inside every open Risk card on Today (executive section), absent on resolved/dismissed risks | frontend | TodayPage.test.tsx::renders per-risk Share button on open risks only |
| 7 | Per-risk Share opens native share sheet with the risk artifact + referral footer; clipboard fallback + success toast when no navigator.share | frontend | TodayPage.test.tsx::per-risk Share opens native sheet with the referral-footer artifact / per-risk Share falls back to clipboard when no native share sheet |
| 8 | Per-risk Share button renders on every open Risk card on the /intel/risks triage queue, absent on monitoring/resolved/dismissed risks | frontend | RisksPage.test.tsx::renders per-risk Share button on open risks only |
| 9 | Per-risk Share on /intel/risks opens native share sheet with the same risk artifact + referral footer as Today | frontend | RisksPage.test.tsx::per-risk Share opens native share sheet with the referral-footer artifact |
| 10 | Per-risk Share on /intel/risks falls back to clipboard + success toast when no navigator.share | frontend | RisksPage.test.tsx::per-risk Share falls back to clipboard when no native share sheet |
| 11 | Per-risk share-card image SVG builder: well-formed 1080×1080 doc, eyebrow + date, decision title headline, severity line (always), risk-type line (when populated), description body (when populated), generic vs referral footer, XML-escape, code-point-aware truncate; severity-coloured accent rail + severity line (critical/high/medium/low map to risk-red/signal-amber/lumi-blue/soft-slate); identity-stamp organization name (present, empty/whitespace/undefined fallback, over-long truncate, XML-escape, surrogate-pair safety) | frontend | riskShareCard.test.ts (20 deterministic cases — 10 base + 4 severity-accent + 6 identity-stamp + baseline preservation) |
| 12 | A busy risk groups customer + money separately from mitigation state, with the responsive divider present only between populated lanes | frontend | RisksPage.test.tsx::groups busy risk metadata into exposure and mitigation lanes |
| 13 | Resolve an active Brief risk through the web UI, navigate back without reloading, and observe the existing BriefItem immediately marked dismissed | e2e | lumi.part02.spec.ts::resolving a risk immediately updates its existing CEO Brief item |
| 14 | Bulk dismiss is bounded, all-or-nothing, tenant-isolated, viewer-denied, and dismisses linked BriefItems | backend | test_risks.py::TestRisk::test_bulk_dismiss_* |
| 15 | Bulk mode selects active risks, requires confirmation, calls one endpoint, and hides mutation controls from viewers | frontend | RisksPage.test.tsx::bulk dismisses selected active risks only after explicit confirmation; keeps all mutation controls hidden for viewers |
| 16 | Create two risks, select both in the real browser table, confirm once, and verify both persisted as dismissed | e2e | lumi.part02.spec.ts::can select and dismiss multiple active risks atomically |
8. Current Behavior
Shipped. Risks exist as tenant-scoped operational items with evidence lineage, UI management flows, and the expected intelligence-surface behavior. Severity ordering fix (A-r26) ensures critical/high risks surface first. Resolve/dismiss now immediately marks linked BriefItems dismissed and invalidates the web Brief cache, so the resolved risk cannot remain active during SPA navigation. Non-viewers can bulk-dismiss up to 100 selected active risks atomically; linked BriefItems leave the queue in the same transaction. Money exposure is visible in list rows with severity-weighted emphasis. Mitigation tracking shows amber warning for unlinked high/critical risks. Active viewers remain read-only in both API permissions and web affordances. P1–P4 resolved 2026-06-14; P5 (customer name extraction) pending LLM-mode F08.
9. Error States
| # | Error | Response | Notes |
|---|---|---|---|
| ES1 | Missing required title | 400 {"title": ["This field is required."]} | Implicit in CRUD tests |
| ES2 | Invalid risk_type | 400 (model choices) | test_create_risk_rejects_invalid_risk_type |
| ES3 | Invalid severity/likelihood/status | 400 (model choices) | test_create_risk_rejects_invalid_severity |
| ES4 | Cross-tenant risk access | 404 | test_risk_tenant_isolation |
| ES5 | Cross-tenant related_task/related_commitment/evidence_sources FK | 400 — RiskSerializer validates all three | test_risk_cross_tenant_related_task_fk_rejected; test_risk_cross_tenant_related_commitment_rejected; test_risk_cross_tenant_evidence_source_rejected (fixed 2026-06-23) |
| ES6 | Frontend load failure | toast.error(t('error_load')) | No retry; no E2E test |
| ES7 | Frontend create failure | toast.error(t('error_create')) | No E2E test |
| ES8 | Frontend resolve/dismiss failure | toast.error(t('error_resolve')) or toast.error(t('error_dismiss')); list reloads | No E2E test |
| ES9 | ?status=monitoring — valid but no risks in monitoring | Returns empty list (200) | Valid behavior; no explicit test |
| ES10 | Resolving already-resolved risk | resolve re-stamps resolved_at; returns 200 idempotent | test_resolve_risk_idempotent |
| ES11 | Dismiss without evidence | dismiss accepts zero evidence — by design (CEO trust model) | No guardrail; covered by E3 |
| ES12 | Keyboard-only status filtering | Shared Radix select supports focus + Space + ArrowDown + Enter | Real-Chromium can filter risks by status journey uses keyboard only |
| ES13 | Bulk selection contains invalid, inactive, duplicate, or foreign-tenant IDs | Generic 400; no selected risk changes | Bounded validation and transaction.atomic() prevent partial triage and tenant-ID probing |
| ES14 | Shared-mobile create/update receives a localized severity label | Normalize English/Vietnamese display labels to low, medium, high, or critical before transport; unknown values still reach backend validation | Shared JVM regressions cover production-observed cao and High; named-device proof remains open |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md Risk state machine · GLOSSARY.
11. Implementation Audit (2026-06-14)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Severity ordering: severity_rank annotation, -severity_rank,-created_at | ✅ match | A-r26; test_risks_r26.py 3 tests; xfail removed |
| 2 | monitoring status available as a one-tap CEO verb | ✅ match | model STATUS_CHOICES; ?status=monitoring filter works |
| 3 | High/critical risks in Brief §2, ordered by severity | ✅ match | P1 resolved 2026-06-14; generate_morning_brief filters severity__in=["high","critical"] |
| 4 | Resolve/dismiss immediately dismisses existing BriefItems referencing the risk | ✅ match | P2 resolved 2026-06-14; test_resolve_dismisses_existing_brief_items; test_dismiss_dismisses_existing_brief_items |
| 5 | dismiss does NOT set resolved_at; resolve does (asymmetric by design) | ✅ match | P2; test_dismiss_risk_does_not_set_resolved_at |
| 6 | Money amount in list row with severity-weighted emphasis (bold red for high/critical, amber for lower) | ✅ match | P3 resolved 2026-06-14; RisksPage styling |
| 7 | Mitigation tracking: green indicator when related_task/related_commitment set; amber “no mitigation” for unlinked high/critical | ✅ match | P4 resolved 2026-06-14; i18n keys mitigation_linked/no_mitigation |
| 8 | related_customer_name populated and company FK set from pulse customer name extraction | ⚠️ gap | P5: mock extraction (F08) doesn’t extract customer names; requires LLM-mode F08 §10 |
| 9 | F08 assigns every extracted risk operations/medium — upstream typing gap | ⚠️ gap | §9 verdict “weak link is upstream: F08 labels every risk operations/medium”; tracked F08 §9.3 |
| 10 | Cross-tenant FKs (related_task/related_commitment/evidence_sources) silently succeed | ✅ fixed (2026-06-23) | RiskSerializer now has validate_evidence_sources, validate_related_task, validate_related_commitment — all return 400 for cross-org FK. Tests renamed: test_risk_cross_tenant_evidence_source_rejected, test_risk_cross_tenant_related_task_fk_rejected, test_risk_cross_tenant_related_commitment_rejected. |
| 11 | Accessibility: severity filter keyboard nav, Vietnamese aria-labels for severity badges | ⚠️ partial | Vietnamese severity badge labels + aria-label="Mức độ: …" shipped 2026-06-18 (RisksPage.test.tsx); keyboard-only filter E2E remains unverified (ES12). |
| 12 | Visual QC: Vietnamese risk severity pills show localized labels, not backend literals | ✅ match | 2026-06-18 screenshot pass; RisksPage.tsx passes translated common.priority_* labels to StatusBadge; RisksPage.test.tsx |
| 13 | Visual QC: risk cards do not repeat the same sentence as both title and description | ✅ match | 2026-06-18 list polish; RisksPage.tsx suppresses duplicate descriptions so AI-generated cards stay compact in screenshots |
Summary: 10/13 match; 2 gaps (customer extraction, upstream typing) + 1 partial accessibility gap (keyboard-only severity/status filter E2E remains unverified). Cross-tenant FK gap closed 2026-06-23.
Edge Cases Catalog (formerly §11, 2026-06-12): 14 edge cases (E1–E14) documented: no mitigation, severity escalation (no FSM), resolve without evidence, cross-team visibility (org-only), AI field spoofing, risk aging (no mechanism), max active (no limit), dismiss/resolved_at asymmetry, resolve/dismiss overwrite behavior, customer_name/company inconsistency, money_amount without label, owner SET_NULL, concurrent resolve/dismiss, cross-tenant FKs. Key items integrated into audit rows 8–11 and §9 error states. Full catalog in git history (2026-06-12 changelog entry).
12. Open Questions / Decisions
- Refuse risk-matrix feature requests; the Brief ranking is the prioritization.
Known implementation gaps (P1–P5 tracking)
Priority order (P1–P4 resolved 2026-06-14; only P5 remains, blocked on LLM-mode extraction F08):
P1 — Brief integration (§2 story 2, ACs lines 26 + 35 + 36): ✅ RESOLVED 2026-06-14.
- Severity filter active;
generate_morning_briefqueues onlystatus="open", severity__in=["high","critical"]risks. - Risk BriefItem links
related_riskwith one-tap “Giao việc” assign action (F25). - Assign chain carries full context: created Task gets
[SEVERITY] title — reasoning+ inherits risk’sevidence_sources. - Deferred: evidence summary inline on Brief card (no Brief UI renders
evidence_sourcestoday). Revisit when specced.
P2 — Status → Brief sync (§2 story 3, AC line 29): ✅ RESOLVED 2026-06-14. RiskViewSet.resolve/dismiss now dismiss open BriefItems referencing the risk (dismissed_at stamped). resolved_at stamped on resolve (not dismiss). Tested: test_resolve_dismisses_existing_brief_items, test_dismiss_dismisses_existing_brief_items.
P3 — Money exposure in list (§2 story 1, ACs lines 27 + 33): ✅ RESOLVED 2026-06-14. RisksPage rows render money_amount+currency with severity-weighted emphasis. Tested.
P4 — Mitigation tracking in list (§2 story 4, ACs lines 38 + 39): ✅ RESOLVED 2026-06-14. Green/amber mitigation indicators in list rows. i18n keys mitigation_linked/no_mitigation added across launch-market locales.
P5 — Customer name extraction (§2 story 1, AC line 23): When the pulse text mentions a customer by name, related_customer_name should be populated; if the name matches a known crm.Company, company FK should be set. Mock extraction (F08) does not do this. Requires LLM-mode extraction (F08 §10).
13. Bug / Change Log
2026-07-25 — Canonicalize localized mobile severity before transport
Type: Production bug fix
Summary:
- Sentry LUMI-ANDROID-24 recorded two production creates sending display labels (
cao, thenHigh) to the canonical risk API. - Shared create/update now normalize English and Vietnamese severity labels to the backend enum while preserving backend rejection for unknown values.
- Two production-shaped shared JVM regressions protect the exact observed inputs.
Evidence boundary:
- The unfiltered shared JVM suite passes. This is source/model proof, not named Android or iOS runtime proof.
2026-07-14 — Keep risk lifecycle and CEO Brief cache coherent
Type: Bug fix + E2E proof
Summary:
- Risk resolve/dismiss mutations now invalidate both the risk list and today’s Brief query.
- Added a real Chromium flow that primes the Brief cache, resolves the linked risk through the UI, returns through in-app navigation, and requires the existing BriefItem to be dismissed immediately.
- Added focused mutation-hook coverage for both resolve and dismiss invalidation contracts.
- Made the shared E2E login helper locale-independent for clean-browser runs.
Verification:
playwright test lumi.part02.spec.ts --project=chromium --grep "resolving a risk immediately"bash scripts/vitest-lock.sh run src/test/hooks/useRisks.test.tsx src/test/intel/RisksPage.test.tsx./node_modules/.bin/tsc --noEmit
2026-07-14 — Group exposure and mitigation metadata
Type: UI polish
Summary:
- Grouped customer and money exposure separately from mitigation state inside one restrained metadata well.
- Added a responsive divider only when both groups exist; sparse risks retain a compact layout.
- Added a focused frontend layout test and real-browser before/after evidence.
Verification:
bash scripts/vitest-lock.sh run src/test/intel/RisksPage.test.tsxpnpm exec playwright test screenshot-flow.spec.ts --project=chromium --grep 10-risks./node_modules/.bin/tsc --noEmit
2026-07-13 — Enforce viewer read-only risk permissions
Type: Security hardening / Contract Graph proof
Summary:
RiskViewSetnow appliesIsRiskWriterto create, update, partial update, delete, resolve, and dismiss actions while preserving viewer read access.- Added executable proofs for viewer create/dismiss denial, member allow paths, invalid risk type, and tenant isolation; F10 is now represented by a canonical CUE contract.
- The remaining F10 gap is explicit: frontend unit coverage, browser journey evidence, and named Android/iOS/macOS runtime evidence are not part of this web API slice.
2026-07-10 — W-AIQ-05: Risks’ evidence chip resolves (cross-reference)
Type: Feature (P0 — “evidence on every AI output” rail, read side)
Summary:
RisksPage.tsx’sEvidenceChip(§6) is now wrapped inEvidencePopover— clicking a chip with ≥1 evidence-source id opens a popover with the source’s title/snippet and, when resolvable, an “Open source” link. Full design, backend serializer change, and test list documented once at F12 brief §13, 2026-07-10 (this is the shared primitive every evidence-chip consumer uses) — recorded here only as a pointer per this row’s “pick a primary, cross-reference” convention.
Impact:
- No Risks-specific behavior beyond adopting the shared
EvidencePopovercomponent;r.evidence_sources(already an array of realEvidenceSourceids) is passed straight through.
2026-06-23 — Close cross-tenant FK validation gap (security hardening)
Type: Security fix
Summary:
RiskSerializerpreviously had no validation onevidence_sources,related_task, orrelated_commitment— an org-A user could POST a risk that silently linked EvidenceSource, Task, or Commitment objects owned by org B. This is a Six Rules #1 violation.- Added
validate_evidence_sources,validate_related_task, andvalidate_related_commitmenttoRiskSerializer— same pattern asCommitmentSerializerwhich already had this fix. All three return400for cross-org FK inputs. - Tests updated:
test_risk_cross_tenant_evidence_source_accepted→test_risk_cross_tenant_evidence_source_rejected(assert 400);test_risk_cross_tenant_related_task_fk_accepted→test_risk_cross_tenant_related_task_fk_rejected(assert 400); newtest_risk_cross_tenant_related_commitment_rejectedadded. - §11 audit row 10 closed; §9 ES5 updated.
2026-06-23 — Per-risk share-card image (F10 × F02 × F12 — second leg of per-item visual identity-stamp arc)
Type: Feature (growth · distribution-object · visual artifact)
Summary:
- New pure SVG builder
frontend/src/lib/riskShareCard.tsproduces a deterministic 1080×1080 branded PNG card for one open risk: identity-stamp letterhead at top-left,RISKeyebrow + brand mark + today’s date, risk title as headline (wrap up to 4 lines × 30 chars, navy weight 700 size 50), severity line in the accent colour, type line, optional description excerpt (up to 2 lines), F59-referral doorway-footer band. Adds a severity-coloured accent rail on the left edge of the white sheet (critical → risk-red#C2410C, high → signal-amber#F4A62A, medium → lumi-blue#0E3EB8, low → soft-slate#5E6677) — matches the RisksPage.tsxseverityBorderLefttoken mapping so the forwarded thumbnail carries the same priority signal at chat-thumbnail size that the in-app row carries at desktop size. MirrorsdecisionShareCard.ts1:1 (palette,esc/cp/truncate/wrap/textElhelpers, identity-stamp position, eyebrow shape, doorway band). - New orchestration hook
frontend/src/hooks/useRiskShareImage.tsreusessvgToPngBlobfrombriefImage.tsto rasterise, then runs the same Web Share API ({ files, text }) → download fallback chainuseDecisionShareImage/ BriefPage.handleShareImage already use.buildRiskShareTextartifact rides along as the share-sheettextpayload so a recipient with image previews disabled still gets readable context. - Sibling
ImageDownbutton next to the existing text-Share button on both RisksPage and TodayPage Risk section. Disabled while saving; localized aria-label and tooltip via the reusedbrief.share_image_buttonkey. - Two new keys in
risks.json:card_eyebrow(“RISK”) andcard_share_image_saved(“Risk image saved — share it to the owner”). Vietnamese hand-translated (RỦI RO/Đã lưu ảnh rủi ro — chia sẻ cho người phụ trách); 60 other locales seeded with the English value bypropagate-new-i18n-keys.mjs.
Why:
- Closes the second leg of the per-item visual identity-stamp arc that PR #472 (
c1a3d159) opened with the per-decision share-card image. The text leg of the intel-trio identity-stamp arc closed on 2026-06-23 (commitments ✅ #448 / risks ✅ #453 / decisions ✅ #461) and the visual arc opened the same day on decisions (#472). The per-risk card is the natural second move because the per-risk forward is the highest-volume named-owner gesture in the share family (“@head_of_finance, Khách A — 90 ngày — your call”) — every high/critical risk on a CEO’s morning Today queue triggers a triage-forward to a named owner, who is usually a peer the sender knows by name and often has hiring/budgeting authority themselves (i.e. a future Lumi buyer). - In Lumi’s image-first wedge markets (Zalo / WhatsApp / Messenger / Viber / board decks) screenshots forward where pasted text doesn’t — image previews render natively in every chat, get re-screenshotted into group chats where text formatting wouldn’t survive, and read as “board-deck quality” artifacts. PR #310 / #437 shipped the whole-brief share-card image for exactly this reason; #472 shipped the per-decision image; this PR closes the second of the three per-item legs.
- Tie-breaker (existing promise made truer): the whole-brief image already promises a branded forwardable visual, the per-risk text already promises identifiable letterhead, the per-decision image now promises both on the highest-stakes co-decider artifact. This PR lifts both promises into the intersection on the second per-item surface — additive image affordance on a feature whose share contract is already shipped, no new surface.
Verification:
pnpm test -- src/test/riskShareCard.test.ts— 20/20 green (10 base + 4 severity-accent + 5 identity-stamp + 1 baseline preservation).pnpm test -- src/test/riskShare.test.ts src/test/RisksPage.test.tsx src/test/TodayPage.test.tsx src/test/decisionShareCard.test.ts src/test/decisionShare.test.ts src/test/DecisionsPage.test.tsx src/test/briefShareCard.test.ts src/test/briefShare.test.ts src/test/briefItemShare.test.ts src/test/commitmentShare.test.ts src/test/CommitmentsPage.test.tsx— 178/178 sibling assurance green (text-share and the prior per-decision image card are unchanged; pages render the new button without regression).- Full frontend suite
pnpm test— 10,129/10,129 green (16 skipped — exactly +20 from the per-decision baseline of 10,109, matching the new riskShareCard test count). ./node_modules/.bin/tsc --noEmit— 0 errors.node scripts/check-design-tokens.mjs— clean (SVG palette is inlined hexes per DESIGN.md §16.1; no stock-Tailwind hits in the new React-tree ImageDown buttons).pnpm validate-i18n— parity green across all 62 locales (120 entries added across 60 non-en non-vi locales; exactly 2 keys × 60 locales).
Growth metric this bets on: per-risk share-card-image attributed signups per forwarded artifact — the per-risk image is the highest-volume named-owner forward in the image format (every high/critical risk on a CEO’s morning Today queue triggers the triage-forward gesture to one named owner). In chat-first wedge markets the image form should out-perform the text form on attributed-signup-per-share by ≥1.5× because the receiver sees a board-deck-quality preview in the chat thumbnail with the severity rail visible at thumbnail scale — not a wall of pasted text that loses formatting on group-forward. Secondary: time from non-user → first share click on a receiver who sees Acme Corp · RISK · 2026-06-23 / Cash runway — 6 weeks left / ⚠ Severity: high / 🏷 Type: financial / Made with Lumi · Join free → … reads it as a real triage tool from a peer’s company, not anonymous marketing. Tertiary: per-item image-arc closure rate — at the close of this PR, the share family reaches 2/3 image-arc coverage for the intel trio (decisions ✅ #472, risks ✅ this PR, commitments ⏳).
2026-06-22 — Growth/identity-stamp: stamp the sender’s organization name on the per-risk text share artifact (F10 × F02)
Type: Feature (growth / distribution-object — additive)
-
Closes the second of the three intel-trio identity-stamp gaps opened by the per-commitment artifact (#448, PR #448). The per-risk share is the second-highest-volume named-owner forward in the product (“@head_of_finance, Khách A — 90 ngày — your call”), and the head of the share artifact now reads as identifiable letterhead (
{org} · Lumi · Risk · {date}) rather than the anonymousLumi · Risk · {date}. The receiver — a named owner who reads the artifact personally — sees the sender’s company in line 1, the Notion/Figma/Calendly distribution-object invariant on the second-most-natural CEO forward gesture. The per-decision rollout follows as the third leg of the intel-trio arc. -
frontend/src/lib/riskShare.ts— adds an optionalorgName?: stringparameter (last position, afterreferralUrl, mirroringbriefItemShare.tsandcommitmentShare.ts). When trimmed non-empty, line 1 becomest('share_header_with_org', { ns: 'risks', org, date })(or the_nodatevariant whentodayDateis null); when empty / whitespace / undefined the existingshare_header/share_header_nodatecode path runs unchanged. Bit-for-bit identical artifact for any caller that doesn’t pass the new parameter — every pre-existing test stays green. -
frontend/src/hooks/useRiskShare.ts— addsconst orgName = useAuthStore((s) => s.organization?.name)(the same selectoruseBriefShare.ts,useBriefItemShare.ts, anduseCommitmentShare.tsuse) and threads it as the fifth argument tobuildRiskShareText. No change to the share-sheet → clipboard fallback chain, toasts, orAbortErrorhandling — the hook contract is preserved for both callers (the Today Risks section’sBriefSectioncards and the/intel/riskstriage queue’sRisksPage). -
Two new keys in the
risksnamespace:share_header_with_org({{org}} · Lumi · Risk · {{date}}in EN;{{org}} · Lumi · Rủi ro · {{date}}in VI) andshare_header_with_org_nodate({{org}} · Lumi · Riskin EN;{{org}} · Lumi · Rủi roin VI). The remaining 60 locales receive English placeholders vianode scripts/propagate-new-i18n-keys.mjs(parity green now, per-locale translation later via thei18n-translatefleet — the same first-step convention #392/#437/#442/#448 followed for thebriefandcommitmentsnamespaces). -
Tested in
frontend/src/test/riskShare.test.ts— four new TDD-red cases (12/12 green after implementation): present dated, present undated, defensive whitespace fallback, and identity + referral footer co-exist. The 8 pre-existing cases cover the no-orgNameregression contract (optional parameter → anonymous header → bit-for-bit identical artifact for legacy callers). -
Growth metric this bets on: per-risk-share-attributed signups per forwarded risk — the per-risk share is the second-highest-volume named-owner forward in the product (every high/critical risk on a CEO’s morning Today queue triggers the triage-forward gesture to one named owner). Identifiable letterhead on the gesture aimed at a named receiver should out-perform the anonymous header in signup attribution. Tertiary: non-user → first share time on a receiver who sees “Acme Corp · Lumi · Risk · 22 Jun 2026 / ⚠ Severity: high / 🏷 Type: financial / Khách A đã nợ 90 ngày” — they read it as their CEO’s risk-triage tool and are more likely to recognize Lumi as a real product than from the anonymous header.
Verification:
frontend/src/test/riskShare.test.ts(12 cases — 8 original + 4 identity-stamp) — all green.frontend/src/test/RisksPage.test.tsx·TodayPage.test.tsx·briefShare.test.ts·briefItemShare.test.ts·briefShareCard.test.ts·commitmentShare.test.ts— all green (the hook’s caller surfaces and the prior identity-stamp artifacts are unchanged)../node_modules/.bin/tsc --noEmit— 0 errors.node scripts/check-design-tokens.mjs— clean.pnpm validate-i18n— parity green across all 62 locales (120 entries added across 60 non-en non-vi locales, exactly 2 keys × 60 locales).- Full frontend suite
pnpm test— 10,090/10,090 green (matches the per-commitment ledger baseline +4 new tests).
2026-06-22 — Growth/atomic-distribution: per-risk Share extended to the /intel/risks triage queue
Type: Growth / Distribution
Summary:
-
The per-risk atomic Share on Today (#407, same day) extended to the deeper
/intel/riskstriage surface — the page CEOs walk through every open and monitoring risk on during morning triage. Today catches the brief-surfaced risks; the triage queue catches the ones beneath that. SameuseRiskSharehook, sameriskShare.tshelper, same artifact, sameshare_copied/share_errortoasts from thebriefnamespace, samer.status === 'open'gate, same aria-label shape, sametodayIsoderivation — so an artifact forwarded from either surface lands identically in a Zalo thread. The two surfaces cannot drift. -
Added a ghost-variant
Share2icon button at the head of the action cluster (Share · Resolve · Dismiss) on every open Risk card infrontend/src/pages/intel/RisksPage.tsx. Monitoring/resolved/ dismissed risks never expose the affordance (they are not actionable forwards). The same executive-gate scoping applies via existing org filtering onuseRisksList. -
No new code outside the page. Zero new helper, zero new hook, zero new i18n key — every string already lives in the
risksnamespace (share_button,share_header,share_header_nodate,share_severity,share_typefrom #407) and the sharedbriefnamespace (share_footer/share_footer_referral/share_copied/share_error). One copy edit on those keys still lands on every share surface at once — single source of truth for the loop’s copy. -
Tested in
frontend/src/test/RisksPage.test.tsx(3 new component cases): button presence on open risks only / monitoring/resolved absent; native share sheet with referral footer + severity line; clipboard fallback + success toast. -
Behavior-preserving: existing filter select, severity ordering, money exposure rail, mitigation badge, feedback buttons, resolve/dismiss flow all untouched. All 12 prior
RisksPage.test.tsxcases pass unchanged. All 10066 unit tests + zero TS errors + design-tokens gate- i18n parity all green locally before PR.
-
Growth metric this bets on: shares per active CEO per week (same metric the F12 share run series + #398/#407/#413 opened). Secondary: shares originating from
/intel/risksas a fraction of total risk shares — measures whether the deeper triage surface adds throughput beyond Today. Tertiary: F59 referral-attributed signups downstream. -
Files modified:
-
frontend/src/pages/intel/RisksPage.tsx(Share button + hook + todayIso). -
frontend/src/test/RisksPage.test.tsx(3 new cases). -
docs/specs/F10-risks.md§6 + §7 + §13 (this entry). -
docs/improvements/2026-06/week-04/f10-risks-page-per-risk-share-atomic-distribution.md(decision record). -
No backend change, no migration, no diagram change, no MVP_SCOPE matrix change (F10 ✅ unchanged; F59 ✅ unchanged), no POLISH bump (additive growth on a shipped surface, not a defect fix).
2026-06-22 — Growth/atomic-distribution: per-risk Share on every Risk card on Today
Type: Growth / Distribution
Summary:
-
The brief-share viral loop (F12 whole-brief share #270 + referral footer #287 + reward nudge + image card #310 +
?ref=register conversion #321 + Today PageHeader share affordance #350 + per-item brief share #381 + brief-share org-name header #392 + per-decision share on Today #398) extended to the second-most-natural CEO forward gesture: the risk forward (“@head_of_finance, this is yours”). Per-decision share (#398) proved the atomic-share pattern on Today; this run extends it to the Risks section so both of the highest- frequency CEO forward gestures on Today are one tap with no screenshot, no retyped Zalo message, and a branded F59-attributed doorway in the footer. -
Added a ghost-variant
Share2icon button to every open Risk card on Today (r.status === 'open'— resolved/dismissed risks never expose the share affordance). Section stays executive-gated (no member exposure). Click triggers the newuseRiskSharehook — same fallback chain, sameAbortErrorno-fallback, sameshare_copied/share_errortoasts asuseBriefShare/useBriefItemShare/useDecisionShare, so the four share surfaces can never drift on semantics. -
Added pure
frontend/src/lib/riskShare.ts—buildRiskShareText(risk, today, t, referralUrl?). Risk-specific artifact:Lumi · Risk · {date}header (or undated fallback), title,⚠ Severity: {severity}line (always rendered), optional🏷 Type: {risk_type}line, optional description line, and the same generic-or-referral footer using thebriefnamespace’s existingshare_footer/share_footer_referralkeys (already translated in all 62 locales — same footer copy as every other share, one source of truth). -
Five new i18n keys in
risksnamespace (share_button,share_header,share_header_nodate,share_severity,share_type) in en + vi, propagated to all 60 other localerisks.jsonfiles vianode scripts/propagate-new-i18n-keys.mjs. No new keys added tobriefnamespace — the per-risk share reuses the brief footer + toast keys so a future copy edit lands on four share surfaces at once. -
Behavior-preserving for every existing path: whole-brief share button on Today’s
PageHeader.actionsuntouched; per-decision Share button untouched; per-item brief share on/intel/briefuntouched; Risk card’s existing severity badge + risk_type label + severity rail untouched. -
Tested in
frontend/src/test/riskShare.test.ts(8 deterministic cases — dated/undated headers, title verbatim, severity line, risk- type line, description line, full ordering, generic vs referral footer) andTodayPage.test.tsx(3 new cases — button renders on open risks only, native share with referral artifact, clipboard fallback + success toast). -
Growth metric this bets on: shares per active CEO per week (same metric the F12 share run series + #398 opened). Hypothesis: per-risk shares from Today will land differently from decision shares — risks are triage-forward (“you own this fire”) versus decisions which are triage-back (“weigh in on this”). The two together cover the highest-frequency CEO forward gestures the day produces.
Verification:
frontend/src/test/riskShare.test.ts(8 cases) — all green.frontend/src/test/TodayPage.test.tsx(3 new cases) — all green../node_modules/.bin/tsc --noEmit— 0 errors.node scripts/check-design-tokens.mjs— clean (the new button uses shadcn<Button variant="ghost" size="sm">+ LucideShare2).pnpm validate-i18n— parity green across all 62 locales afterpropagate-new-i18n-keys.mjspropagation.
2026-06-21 — CRUD-scope decision: lifecycle actions, not free delete (recorded)
Type: Decision (no code change)
During the 2026-06-21 full-CRUD audit, Risks were reviewed for edit/delete affordances. Decision: Risks are evidence-bound items (each links an EvidenceSource); their UI lifecycle is resolve / dismiss, not free edit/delete. A hard delete would sever the evidence/audit trail (F16) and is intentionally not offered in the operator UI. This is a deliberate scope boundary, not a missing feature.
2026-06-18 — Visual QC: remove duplicate risk body copy
Type: UX Fix
Summary:
- Several risk cards repeated the exact same text as both the title and the description, especially on AI-generated entries, which made the queue feel longer and noisier than the underlying signal.
- Risk cards now suppress the description when it duplicates the title, preserving the evidence/reasoning line while tightening the list scan.
Verification:
frontend/screenshots/10-risks/01-risks-list.pngpnpm test -- src/test/RisksPage.test.tsxpnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "10-risks"./node_modules/.bin/tsc --noEmit
2026-06-18 — Visual QC: localize risk severity badges
Type: UX + Accessibility Fix
Summary:
- The Vietnamese Risks screenshot showed raw backend severity text (
high,medium) on risk cards. - Risk severity badges now render translated labels from the shared
common.priority_*vocabulary. - Severity badges also expose screen-reader labels such as
Mức độ: Cao, satisfying the badge-label part of §15 while leaving keyboard-only filter E2E as the remaining accessibility gap.
Verification:
frontend/screenshots/10-risks/01-risks-list.pngpnpm test -- src/test/RisksPage.test.tsx src/test/StatusBadge.test.tsxpnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "10-risks"
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /risks -> /intel/risks (app-wide IA restructure; API paths unchanged).
2026-06-14 — Strategy v2 realignment (positioning)
Type: Documentation Change
Summary:
- Reframed the §1 “Competitive differentiation vs ClickUp risk register / Monday risk board” paragraph to “What we replace: nothing — risk lives in the boss’s gut,” using fit-out risk vocabulary (chậm nghiệm thu, công nợ khách, trễ giao vật tư) — the contractor owns no risk tool, so Lumi is the first risk register the company has ever had.
- Replaced ClickUp / Monday / Jira / Asana comparisons across five §2 JTBD stories with the Zalo / Excel / boss’s-memory incumbent.
- Reframed the §14 high/critical-resolution-rate metric (ClickUp/Monday → aging Zalo thread; metric definition unchanged) and the §14 “Competitive framing” bullet (Telegram DMs → Zalo messages, fit-out context).
Reason:
- Aligns with competitive-strategy-v2.md and the registry’s Positioning doctrine (v2): the competitor is Zalo/WhatsApp + Excel + owner memory, not US SaaS; use vertical fit-out vocabulary.
Impact:
- Positioning copy only. No change to data model, API, acceptance criteria, error states, audit verdicts, accessibility criteria, or status.
Tests:
- None (documentation change).
Notes:
- Every product fact, AC, API row, §11 audit verdict, and §15 accessibility criterion left intact. Historical changelog entries (e.g. 2026-06-12 “R24”) preserved verbatim.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Added §2 User Stories heading (JTBD stories were embedded in §1b).
- Numbered “Current Behavior” as §8; §8 Diagrams → §10; §11 Edge Cases absorbed into §11 note; §12 Error States → §9 (table); §9 Implementation Audit → §11 (11-row table); §10 Open Questions → §12; §13 Bug/Change Log stays.
- §11 audit: 11 rows, 7 ✅, 4 ⚠️ gaps (customer extraction, upstream typing, cross-tenant FKs, a11y).
2026-06-13 — G-r26: JTBD stories for Brief-triage and unattended-mitigation awareness, accessibility criteria, ES12
Type: Documentation Change
Summary:
- Added two JTBD stories to §2: CEO triaging from the Brief and CEO tracking unattended high/critical risks.
- Added §2b Accessibility Criteria.
- Added §12 ES12: severity filter keyboard nav failure mode.
2026-06-13 — A-r26: Fix default risk ordering to surface critical/high severity first
Type: Bug Fix
Summary:
RiskViewSet.get_querysetpreviously inheritedMeta.ordering = ["-created_at"]— severity had no effect on list order.- Fixed by annotating
severity_rank(critical=4, high=3, medium=2, low=1) and ordering by-severity_rank, -created_at. - Removed xfail marker from
test_red_r25_e.py::TestF10RisksRedGate::test_risk_default_ordering_severity.
Tests: 3 new tests in test_risks_r26.py.
2026-06-12 — Add Edge Cases and Error States to F10 spec
Type: Documentation Change
Summary:
- Added §11 Edge Cases (E1–E14) and §12 Error States (ES1–ES9).
2026-06-12 — Add Success Metrics section (§1b) to F10 spec
Type: Documentation Change
Summary:
- Added
## 1b. Success Metrics: risk-extraction yield, % high/critical risks with no linked mitigation, mean time to resolve, high/critical resolution rate > 70% within 3 days, competitive framing vs. Telegram DMs, anti-metric.
2026-06-12 — R24: JTBD stories, ClickUp/Monday diff, high/critical resolution-rate metric, zero-setup risk queue
Type: Documentation Change
14. Success Metrics (Appendix)
- Risk-extraction yield ≥ 0.2 per submitted pulse: measured as risks created within 5 minutes of a pulse submission divided by total pulse submissions in the same period. The leading signal that front-line awareness is becoming executive awareness (§1). Lower than commitment yield by design — not every pulse contains a risk; a yield above 0.4 would suggest over-extraction (F08 cap). Tied to §2 story 1 (“‘khách A chưa phản hồi 2 tuần’ registers as a customer risk”).
- % of open high/critical risks with a linked mitigation ≤ 30%: measured as the share of open risks with severity high or critical where both
related_taskandrelated_commitmentare null. A high/critical risk with no mitigation path is a signal the CEO has not acted on — they know about the fire but haven’t assigned anyone to fight it. A declining trend over weeks indicates the feature is moving from awareness to action. Tied to §2 story 2 (“critical first, always”) and §2 story 3 (“dismiss noise, monitor slow burns”). - Mean time to resolve for high/critical risks trending down over 4 weeks: measured as the difference between
created_atandresolved_atfor risks resolved in a given week, averaged per severity level. The lagging proof that earlier visibility leads to faster closure. Tied to §1 (“the fires a CEO finds out about late were visible to someone days earlier”) — if CEOs resolve critical risks faster over time, the feature is working. - High/critical risk resolution rate > 70% within 3 days: measured as the share of risks created in a rolling 7-day window with severity high or critical whose
statusisresolvedordismissedwithin 72 hours ofcreated_at. A high/critical risk that stays open for more than 3 days without resolution or formal dismissal is a fire nobody has confirmed is out. Without Lumi an aging worry just sinks down the Zalo thread and out of memory; Lumi surfaces every critical risk in the Brief every morning until it’s resolved or dismissed — the CEO cannot “forget” it exists. Tied to §1 (the fires the CEO finds out about late) and §2 JTBD (“catch a brewing disaster before it’s too late”). - Competitive framing — Zalo messages and the office grapevine. A Vietnamese SME CEO discovers operational risks through Zalo messages, hallway conversations, or catching someone on site — all of which depend on the reporter remembering and finding the right moment. Lumi’s risk list (§2 story 2) turns a single pulse answer (“có rủi ro nào không?”) into a ranked, severity-coded, evidenced risk register that surfaces to the CEO without anyone needing to “find the right time to tell the boss.” Nothing the contractor uses today — Zalo, an Excel sheet, the boss’s memory — transforms passive awareness into a structured, asynchronous risk queue for the Vietnamese fit-out context.
- Anti-metric — open risk count trending above 30 per 50 active members: an org with more than 30 open risks (all severities) per 50 active members suggests the extraction pipeline (F08) is flagging noise — “might rain today” is not a risk. The Brief’s ranking (§2 story 2, F12) should naturally bury low-severity noise, but if total open risk count grows faster than the CEO resolves them, the signal-to-noise ratio is slipping. Track alongside F08 extraction cap.
15. Accessibility Criteria (Appendix)
The risk list and detail UI target WCAG 2.1 AA compliance for the Vietnamese SME CEO audience — many users access Lumi on mobile in transit (the “5am test” on a phone screen). Key criteria:
- Severity filter keyboard nav. The severity filter (
/intel/risks?severity=critical) is operable via keyboard alone: Tab to the filter dropdown, Enter to open, Arrow keys to navigate options, Enter to select, Escape to close. No mouse-required interaction for filtering. - Screen-reader labels for severity badges. Every severity badge (
Badgecomponent) carries anaria-labeldescribing the severity in Vietnamese:"Mức độ: Nghiêm trọng","Mức độ: Cao","Mức độ: Trung bình","Mức độ: Thấp". The badge’s visual color (red/orange/yellow/gray) is supplemented by the label — never rely on color alone to convey severity. - Risk list row focus-visible. Each risk list row receives a visible focus ring (
focus-visible:ring-2) for keyboard navigation. Arrow keys move between rows; Enter opens the detail panel. The empty state (“Không có rủi ro đang mở”) is a focusable landmark. - Action feedback for screen readers. After resolve/dismiss, a
aria-live="polite"region announces the action result in Vietnamese:"Đã xác nhận rủi ro đã được giải quyết"or"Đã bỏ qua rủi ro". - Money amount semantic structure. When
money_amountis displayed in a list row, the amount is wrapped in a<span>witharia-labelformatting the number as currency in Vietnamese locale (e.g.,"200.000.000 đồng"), so screen readers announce the value correctly rather than reading digit-by-digit.