shipped

Spec: F11 — Decision Requests

Status: shipped · Audited: 2026-06-10

1. Problem & CEO Job

Decide once, explicitly, with context. Vietnamese SME CEOs lose time to silent blockers and actions taken without an explicit greenlight. Decision Requests replace both: pending decisions are deadline-ranked, the CEO decides or delegates in one tap, and decision_text, decision_by, and decided_at form an attributable, immutable record.

What we replace: “anh duyệt giúp em” in Zalo and the boss’s memory. The fit-out contractor has no approvals tool; decisions such as a báo giá, đặt cọc, or vật tư substitution are requested and answered in Zalo, with no record of what was decided, by whom, or when. A critical procurement approval scrolls beside a team-outing request, producing “tôi không biết” / “em tưởng anh duyệt rồi.” Lumi extracts and types decisions from pulse text, ranks critical ones in Brief §3 (F12) by decision_needed_by, and preserves the CEO’s choice with its evidence chain. Decisions arrive in the morning Brief ready to act on, becoming attributable, timestamped records rather than disappearing messages.

2. User Stories & Acceptance Criteria

  • JTBD (member): “When I answer the daily pulse and mention ‘cần xác nhận ngân sách Q3,’ I need that to register as a decision request automatically — so I’m not choosing between escalating (which feels confrontational) and staying quiet (which lets the blocker persist). The alternative is a Zalo message asking ‘anh duyệt giúp em’ that the boss may scroll past — there is no approval item to file. Lumi captures it from one pulse sentence.”
    • Given AI extraction, then DecisionRequest carries evidence, ai_reasoning_summary, honest confidence.
    • Given the pulse mentions decision_needed_by, then that date is populated in the request.
  • JTBD (CEO): “When I open the Brief at 7am, I need decisions ranked by deadline with overdue ones impossible to miss — so I clear blockers before my team reaches me. Without Lumi the “queue” is whatever approval requests are still visible in my Zalo threads, sorted by nothing and easy to miss. Lumi ranks decisions by decision_needed_by in Brief §3 and surfaces overdue ones with critical-urgency context.”
    • Given decision_needed_by ≤ today, then the BriefItem has priority critical and the list row shows a red overdue badge.
    • Given decision_needed_by > today, then priority scales by days remaining (urgent < 3d, standard otherwise).
  • JTBD (CEO): “When I decide, I need to record my actual decision text, not just click approve/reject — so there’s an attributable record the team can act on. A “duyệt nhé” reply in Zalo is a binary yes with no record of what was actually decided, and it vanishes down the thread. Lumi records decision_text (what was decided), decision_by (who), and decided_at (when) as an immutable triple.”
    • Given a decide action, then all three fields are stamped; status → decided; empty/whitespace decision_text is rejected with 400 so rationale is never blank.
    • Given a cancel action, then status → cancelled; all three fields also stamped.
    • Given decide on an already-decided/cancelled request, then 400 is returned (guard prevents overwrite).
  • JTBD (CEO): “When a decision request turns out to be noise or is resolved offline, I need to cancel it in one tap — so the queue stays honest and ‘open’ means ‘real blocker, still pending.’”
    • Given cancel, then the request leaves the Brief; the BriefItem is dismissed.
  • JTBD (CEO): “When I see a decision in the Brief, I need options and the recommended option to be visible — so I can decide without opening a separate document. Today the options are buried in a long Zalo message or an attached Excel file I have to open and read. Lumi renders options_json and recommended_option as structured fields in the detail view.”
    • Given options_json is non-null, then DecisionsPage renders the options list.
    • Given options_json is null, then the field is hidden — no crash (conditional render).
    • options_json and recommended_option are never populated by mock extraction (F08) — requires LLM mode (see §11 row 11).
  • JTBD (manager): “When I discover a decision need outside of pulse, I need to record it with type, deadline, and options right away — so the CEO’s decision queue stays complete even when the AI misses something.”
    • Given manual create with only title and decision_type, then it saves with status open and no deadline required.
  • As a client traversing decision history, I want an indexed cursor path while shipped page clients remain unchanged.
    • pagination=cursor traverses tenant-scoped decisions by descending UUIDv7 id, preserves filters, and avoids deep-page OFFSET.

3. Scope Boundaries

  • In: CRUD, AI extraction, typing/deadline, decide/cancel lifecycle, options rendering, Brief §3 surfacing.
  • Out: multi-step approval chains (F26), role-delegation chains, voting/consensus, external counterparty sign-off.
  • Cross-link (F71 / Loop 2): when a decision needs an external sign-off — a client approving a báo giá / design / handover, a supplier confirming a price or delivery date — the external confirmation is not recorded inside F11. It routes through a confirmation link (F71): the owner sends the counterparty a no-login link to confirm (đã duyệt / đã xác nhận), the confirmation updates the brief and puts a future Lumi buyer in contact with the product. F11 records the internal decision (the CEO’s choice); F71 owns the external sign-off surface.

4. Data Model

operations.DecisionRequest — decision_type (6 choices), status: open → decided | cancelled. decision_needed_by (date, optional), options_json (JSON, optional), recommended_option (str, optional), decision_text (str blank ok), decision_by (FK OrganizationMember, optional), decided_at (datetime, optional). ai_reasoning_summary, confidence_score, evidence_sources. organization FK (tenant-owned).

5. API Contract

MethodPathMin roleNotes
CRUD/api/decisions/member (write) / viewer (read)Router ViewSet, tenant-scoped; page default, pagination=cursor opt-in
POST/api/decisions/<id>/decide/memberStamps decision_text, decision_by, decided_at; guard: non-open → 400
POST/api/decisions/<id>/cancel/memberStamps same triple; guard: non-open → 400

Cursor mode is ordered by descending UUIDv7 id; clients follow opaque links, and alternate ordering is rejected with 400. Both modes retain the shipped {count,next,previous,results} envelope.

6. UI / UX

  • /intel/decisions (DecisionsPage.tsx). Overdue first, deadline-ranked. options_json rendered as structured list; recommended_option highlighted.
  • States: empty (“Không có quyết định đang chờ 🎉”), loading skeletons.
  • i18n: decisions — including share_header_with_org / share_header_with_org_nodate (identity-stamp letterhead added 2026-06-22) and card_eyebrow / card_share_image_saved (per-decision share-card image added 2026-06-23).
  • Per-decision Share affordance on every open decision card (status === 'open') — useDecisionShare hook shared with TodayPage; artifact carries the identity-stamp letterhead on line 1 ({org} · Lumi · Decision · {date}, falls back to the anonymous Lumi · Decision · {date} when the auth store is still hydrating), the title, recommended option, decision-needed-by date, and the CEO’s F59 referral footer when the code resolves; falls back to clipboard with the brief-namespace toast when no native share sheet. Mirrors /intel/risks per-risk Share (#419) and the per-risk org-stamp letterhead (#453); closes the third and final leg of the intel-trio identity-stamp arc after #448 (commitments) and #453 (risks).
  • Per-decision Share-image affordance on every open decision card — sibling ImageDown button next to the text-share button on DecisionsPage and TodayPage, wired to useDecisionShareImage. Builds a 1080×1080 branded PNG card via buildDecisionShareCardSvg: identity-stamp letterhead ({org}) at top-left, DECISION eyebrow with brand mark + the day’s date, the decision title as the headline (wrap up to 4 lines), the recommended option line, the decide-by line in amber, and the F59-referral doorway-footer band. Uses the Web Share API with { files, text } on mobile so the receiver gets both the visual artifact and the text-share fallback in a single forward; falls back to a PNG download with the decisions.card_share_image_saved toast on desktop. Mirrors the whole-brief share-card image (#310 / #437) — opens the per-item image arc; per-risk and per-commitment share-cards follow.

7. Test Plan

#CaseTypeWhere
1CRUD, lifecycle, cross-tenant blockedbackendtests/test_api.py::TestDecision
2decide/cancel guards (non-open → 400)backendtests/test_api.py::TestDecision
3Overdue → Brief priority critical; resolved leaves Briefbackendtests/test_api.py::TestBriefGeneration
4List + decide flowe2edescribe("Decisions")
5Per-decision Share button renders on open decisions only; opens native share with referral footer; falls back to clipboard; identity-stamp letterhead present when org name available (dated + nodate); defensive fallback to anonymous header on empty/whitespace org; identity + referral footer co-existfrontend unitDecisionsPage.test.tsx (renders per-decision Share button on open decisions only, per-decision Share opens native share sheet with the referral-footer artifact, per-decision Share falls back to clipboard when no native share sheet) · decisionShare.test.ts (stamps the sender organization name in the header when present (dated), stamps the sender organization name in the header when present (no date), falls back to the anonymous header when org name is empty or whitespace (defensive), stamps org name alongside referral footer — identity and growth doorway co-exist)
6Per-decision Share-image SVG builder produces a well-formed 1080×1080 SVG; renders the eyebrow + date / decision title / recommended option / decide-by / referral footer; identity-stamp letterhead present when org name available, bit-for-bit fallback when empty/whitespace/undefined; XML-escapes special characters; code-point-aware truncate keeps emoji/CJK intactfrontend unitdecisionShareCard.test.ts (15 cases: 9 base — well-formed SVG · eyebrow + date · undated · title-as-headline · recommend (with/without) · decide-by (with/without) · generic footer · referral footer · XML escape · code-point truncate; 5 identity-stamp — stamps above eyebrow · bit-for-bit omit fallback · long-org truncate · XML escape · emoji safety; +1 baseline check)
7Page compatibility, cursor traversal, tenant isolation, filter retention, no SQL OFFSET, indexbackendtests/core/test_high_value_cursor_pagination.py

8. Current Behavior

Shipped. DecisionRequest CRUD, decide/cancel lifecycle guards, options_json conditional rendering, and Brief §3 integration all live. Decide/cancel both stamp all three fields (decision_text, decision_by, decided_at). Guard returns 400 on non-open status, and decide also returns 400 for empty/whitespace decision_text. options_json renders in DecisionsPage; null options_json does not crash. Audit log covers decide/cancel but not CRUD (known gap). Cross-tenant evidence_sources are now rejected with 400 by serializer validation. options_json and recommended_option are never filled by mock extraction (requires LLM-mode F08).

9. Error States

#ErrorResponseNotes
ES1Missing required title400 {"title": ["This field is required."]}Standard serializer validation
ES2Invalid decision_type400 (model choices)No dedicated test — covered by DRF model-choices serializer validation
ES3Invalid status400 (model choices)test_patch_decision_rejects_invalid_status (tests/api/test_decisions.py ~104)
ES4Decide on non-open (already decided/cancelled)400 {"detail": "Decision already made."}test_decide_on_already_decided_returns_400 (tests/api/test_decisions.py ~236)
ES5Cancel on non-open (already decided/cancelled)400 {"detail": "Decision already cancelled."}test_cancel_non_open_decision_returns_400 (tests/api/test_decisions.py ~94)
ES6Cross-tenant decision access404test_decision_tenant_isolation (tests/api/test_decisions.py ~217)
ES7Cross-tenant evidence_sources FK400 — DecisionRequestSerializer validates org scopetest_decision_cross_tenant_evidence_source_rejected (fixed 2026-06-23)
ES8options_json invalid JSONCurrently undefined — no schema validationoptions_json is a free JSON field; malformed JSON accepted if Python dict; no JSON Schema check
ES9Decide with empty/whitespace decision_text400 {"detail": "decision_text is required."}backend/apps/operations/views.py::decide; test_decide_rejects_empty_decision_text
ES10Frontend load failuretoast.error(t('error_load'))No E2E test
ES11Frontend decide failuretoast.error(t('error_decide'))No E2E test
ES12Frontend cancel failuretoast.error(t('error_cancel'))No E2E test
ES13Decide on overdue request200 — overdue is a display state only; decide/cancel always accepted if request is still open and rationale is non-emptyNo special guard for overdue
ES14decision_needed_by in the past on create200 — no validation; immediately overdueIntentional: CEO can record a decision that was needed yesterday
ES15Audit log on CRUD — create/update/delete not loggedNo error; silent gapKnown: decide/cancel are logged; CRUD ops are not; see §11 row 7

10. Diagrams & Docs Touched

backend/DIAGRAMS.md Decision state machine · GLOSSARY.

11. Implementation Audit (2026-06-14)

#ClaimStatusEvidence
1decide stamps decision_text, decision_by, decided_at; status → decided✅ matchtest_decide (tests/api/test_decisions.py ~24) — stamps and audit log asserted inline
2cancel on decided/cancelled → 400✅ matchtest_cancel_non_open_decision_returns_400 (tests/api/test_decisions.py ~94)
3decide on non-open → 400✅ matchtest_decide_on_already_decided_returns_400 (tests/api/test_decisions.py ~236)
4decision_needed_by ≤ today → BriefItem priority critical✅ matchtest_overdue_decision_becomes_critical_brief_item; Brief §3 ordering verified
5options_json / recommended_option render in DecisionsPage✅ matchDecisionsPage.tsx conditional render; test_options_json_renders_in_decisions_page (E2E)
6Null options_json → conditional render (no crash)✅ matchtest_null_options_json_does_not_crash_decisions_page (E2E)
7Audit log on decide/cancel, but NOT on CRUD create/update/delete⚠️ gapAudit assertions inline in test_decide (~33–38) and test_cancel_decision (~68–73) in tests/api/test_decisions.py; no dedicated CRUD-gap verification test
8decide rejects empty/whitespace decision_text with 400✅ matchbackend/apps/operations/views.py::decide; test_decide_rejects_empty_decision_text; test_decision_empty_rationale_allowed now passes because the old gap is closed
9options_json has no JSON Schema validation⚠️ gapFree JSON field; no validate_options_json method; any Python-serializable structure accepted
10Cross-tenant evidence_sources M2M silently succeeds✅ fixed (2026-06-23)DecisionRequestSerializer now has validate_evidence_sources — returns 400 for cross-org evidence. Test renamed: test_decision_cross_tenant_evidence_source_rejected.
11options_json / recommended_option never populated by mock extraction (F08)⚠️ gapMock extractor only fills title/decision_type/ai_reasoning_summary; options population requires LLM-mode F08 §10
12One-tap cancel action is visible in the list for open requests✅ match2026-06-18 fix; DecisionsPage.tsx renders cancel_request_button; DecisionsPage.test.tsx covers cancelDecision list action
13Visual QC: decision cards do not repeat the same sentence as both title and description✅ match2026-06-18 Decisions pass; DecisionsPage.tsx suppresses duplicate descriptions so quick approvals stay compact in the list

Summary: 10/13 match; 3 gaps remain (audit on CRUD, options_json validation, options never populated by extraction). Empty-rationale enforcement and cross-tenant evidence validation are both now closed in shipped code.

Edge Cases Catalog (formerly §11, 2026-06-12): 18 edge cases (E1–E18) documented. Key items integrated: decide guards (E1→row 2–3), deadline overdue (E2→row 4), options rendering (E3–E4→row 5–6), audit gap (E5→row 7), empty decision_text (E6→row 8), cross-tenant evidence (E9→row 10), options never extracted (E12→row 11). Remaining edge cases (E7 cancel-on-open, E8 cancel stamps, E10 decided_at idempotency, E11 decision_by SET_NULL, E13 no FSM escalation, E14 age with no action, E15 max-open, E16 concurrent decide, E17 decision_needed_by validation, E18 options_json no max-length) — tracked in git history (2026-06-12 changelog entry).

12. Open Questions / Decisions

  • Mandatory decision_text? Deciding now requires non-empty rationale text. Revisit only if field usage data shows the enforcement is adding material friction for executives logging already-made decisions.
  • options_json JSON Schema? Should the serializer validate against a {"options": [...], "recommended": "..."} schema? Low-risk in MVP (manually created); becomes a QA issue when LLM extraction fills it at scale.
  • Audit completeness. decide/cancel are logged; CRUD is not. Decision to extend audit coverage to CRUD belongs in an ADR when F16 is next touched.

13. Bug / Change Log

2026-07-28 — Native decide payload matches the canonical API

The shared Android/iOS client now sends the selected decision as decision_text, matching the backend action contract. Release 21 incorrectly sent option, so valid native decisions received 400 {"detail":"decision_text is required."}. A request-body test prevents either key from drifting independently again; device execution remains required before claiming runtime closure.

2026-07-13 — Enforce viewer read-only decision permissions

DecisionRequestViewSet now applies IsDecisionWriter to create, update, partial update, delete, decide, and cancel actions while preserving viewer read access. Contract Graph proofs cover member, viewer, tenant, lifecycle, and invalid-input behavior; UI/runtime evidence remains an explicit gap.

2026-07-13 — Additive UUIDv7 cursor pagination

Type: Performance + API compatibility

Added pagination=cursor to the decision list with tenant-scoped -id traversal and a matching (organization, -id) index. Existing page-number clients remain unchanged. See docs/test-plan/high-value-cursor-pagination.md for the shared contract and proof matrix.

2026-07-02 — Correct stale test names in §9 and §11 audit (batch-1 spec audit)

Type: Documentation Change

Summary:

  • §11 row 1: test_decide_stamps_all_three_fields / test_decide_sets_status_decided → test_decide (tests/api/test_decisions.py ~24); stamps and audit log are both asserted inline in that single test.
  • §11 row 2: test_cancel_on_decided_decision_returns_400 → test_cancel_non_open_decision_returns_400 (~94).
  • §11 row 3: test_decide_on_decided_decision_returns_400 / test_decide_on_cancelled_decision_returns_400 → test_decide_on_already_decided_returns_400 (~236); one test covers both non-open guard paths.
  • §11 row 7: named tests test_decide_writes_audit_log / test_crud_does_not_write_audit_log do not exist; replaced with reference to inline audit assertions in test_decide (~33) / test_cancel_decision (~68).
  • §9 ES2: test_create_decision_request_rejects_invalid_type does not exist; covered by DRF model-choices validation without a dedicated test.
  • §9 ES3: test_create_decision_request_rejects_invalid_status → test_patch_decision_rejects_invalid_status (~104).
  • §9 ES4: stale test names corrected to match test_decide_on_already_decided_returns_400.
  • §9 ES5: test_cancel_on_decided_decision_returns_400 → test_cancel_non_open_decision_returns_400.
  • §9 ES6: test_decision_request_tenant_isolation → test_decision_tenant_isolation (~217).

Impact:

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

2026-07-02 — Close stale decision-rationale spec drift

Type: Documentation Change

Summary:

  • Updated the F11 acceptance criteria, current-behavior text, error-state table, implementation audit, and open-questions note to match shipped enforcement: decide now rejects empty/whitespace decision_text with 400.
  • Tightened the audit summary so it reports only the three real remaining gaps: CRUD audit logging, options_json schema validation, and extractor population of options.

Reason:

  • The backend code and tests had already moved to mandatory decision rationale, but the spec still described the old “accepted for speed” behavior as if it were current truth.

Impact:

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

2026-06-23 — Close cross-tenant evidence_sources validation gap (security hardening)

Type: Security fix

Summary:

  • DecisionRequestSerializer previously had no validation on evidence_sources — an org-A user could POST a decision that silently linked an EvidenceSource owned by org B. Six Rules #1 violation.
  • Added validate_evidence_sources to DecisionRequestSerializer — same pattern as CommitmentSerializer. Returns 400 for cross-org evidence.
  • Test updated: test_decision_cross_tenant_evidence_source_accepted → test_decision_cross_tenant_evidence_source_rejected (assert 400). §11 audit row 10 closed; §9 ES7 updated.

2026-06-23 — Per-decision share-card image (F11 × F02 × F12 — opens per-item image arc)

Type: Feature (growth · distribution-object · visual artifact)

Summary:

  • New pure SVG builder frontend/src/lib/decisionShareCard.ts produces a deterministic 1080×1080 branded PNG card for one open decision: identity-stamp letterhead at top-left, DECISION eyebrow + brand mark + today’s date, decision title as headline (wrap up to 4 lines × 30 chars, navy weight 700 size 50), recommended-option line, decide-by line in amber, F59-referral doorway-footer band. Mirrors briefShareCard.ts patterns 1:1 (palette, esc/cp/truncate/wrap/textEl helpers, identity-stamp position, eyebrow shape, doorway band).
  • New orchestration hook frontend/src/hooks/useDecisionShareImage.ts reuses svgToPngBlob from briefImage.ts to rasterise, then runs the same Web Share API ({ files, text }) → download fallback chain BriefPage.handleShareImage already uses. buildDecisionShareText artifact rides along as the share-sheet text payload so a recipient with image previews disabled still gets readable context.
  • Sibling ImageDown button next to the existing text-Share button on both DecisionsPage decision cards and TodayPage decision section. Disabled while saving; localized aria-label and tooltip via the reused brief.share_image_button key.
  • Two new keys in decisions.json: card_eyebrow (“DECISION”) and card_share_image_saved (“Decision image saved — share it to your co-decider”). Vietnamese hand-translated (QUYẾT ĐỊNH / Đã lưu ảnh quyết định — chia sẻ cho người đồng quyết); 60 other locales seeded with the English value by propagate-new-i18n-keys.mjs.

Why:

  • Opens the per-item visual identity-stamp arc. The text leg of the intel-trio identity-stamp arc closed with #461 earlier today (commitments ✅ #448 / risks ✅ #453 / decisions ✅ #461). 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, but the per-decision / per-risk / per-commitment artifacts — the atomic ones a CEO forwards one-at-a-time to a named peer — had no image form at all. This run starts that arc on the highest-stakes named-co-decider artifact; per-risk and per-commitment image cards follow in subsequent runs.
  • The per-decision forward is the highest-stakes named-co-decider gesture in the share family (“@cofounder, decide this; your call by Friday”); the receiver almost always knows the sender’s company by name and almost always has hiring/budgeting authority themselves (i.e. is a future Lumi buyer). Lifting that forward into the most spreadable form (image > text in chat-first wedge markets) makes the highest-conversion gesture in the share family land in its highest-conversion shape.

Verification:

  • pnpm test -- src/test/decisionShareCard.test.ts — 15/15 green (9 base + 5 identity-stamp + 1 baseline preservation).
  • pnpm test -- src/test/decisionShare.test.ts src/test/DecisionsPage.test.tsx src/test/TodayPage.test.tsx — 72/72 green (text-share unchanged; pages render the new button without regression).
  • Sibling assurance: pnpm test -- src/test/briefShareCard.test.ts src/test/briefShare.test.ts src/test/briefItemShare.test.ts src/test/riskShare.test.ts src/test/commitmentShare.test.ts src/test/RisksPage.test.tsx src/test/CommitmentsPage.test.tsx — 106/106 green.
  • Full frontend suite pnpm test — 10,109/10,109 green (16 skipped — same baseline as main).
  • ./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 button).
  • pnpm validate-i18n — parity green across all 62 locales (120 entries added across 60 non-en non-vi locales; 2 keys × 60 locales).

2026-06-23 — Stamp organization name on per-decision text share header (F11 × F02 — closes intel-trio identity-stamp arc)

Type: Feature (growth · distribution-object · identity stamp)

Summary:

  • useDecisionShare now reads organization?.name from useAuthStore and threads it into buildDecisionShareText as the fifth (last, optional) positional parameter — same shape as useRiskShare (#453) and useCommitmentShare (#448).
  • When non-empty (post-trim), line 1 of the artifact reads {org} · Lumi · Decision · {date} (or {org} · Lumi · Decision when todayDate is null) — the identity-stamp letterhead invariant. Empty / whitespace / undefined falls back to the anonymous Lumi · Decision · … form so sharing is never blocked.
  • Two new keys in decisions.json: share_header_with_org and share_header_with_org_nodate. Vietnamese hand-translated ({{org}} · Lumi · Quyết định · {{date}}); 60 other locales seeded with the English value by propagate-new-i18n-keys.mjs — full translation arrives via the standing i18n-translate fleet.

Why:

  • Closes the third and final leg of the intel-trio identity-stamp arc that PR #392 (whole-brief), #437 (share-card image), #442 (per-item brief), #448 (commitments), and #453 (risks) opened. After this run every CEO-forwarded artifact in the product carries identifiable letterhead, not anonymous Lumi marketing — the Notion / Figma / Calendly distribution-object invariant (“the artifact IS the company on the page”).
  • The per-decision forward is the highest-stakes named-co-decider gesture in the share family (“@cofounder, decide this; your call by Friday”). The receiver almost always knows the sender’s company by name; the stamped letterhead reads as a real tool a peer is using on their work, lifting both per-share conversion and zero-cost brand recognition.

Verification:

  • pnpm test -- src/test/decisionShare.test.ts — 11/11 green (4 new identity-stamp cases + the existing 7).
  • Sibling assurance: pnpm test -- src/test/TodayPage.test.tsx src/test/DecisionsPage.test.tsx src/test/RisksPage.test.tsx src/test/CommitmentsPage.test.tsx src/test/riskShare.test.ts src/test/commitmentShare.test.ts src/test/briefShare.test.ts src/test/briefItemShare.test.ts src/test/briefShareCard.test.ts — 152/152 green; the per-decision callers (TodayPage and DecisionsPage) are unchanged.
  • ./node_modules/.bin/tsc --noEmit — 0 errors.
  • node scripts/check-design-tokens.mjs — clean (string-assembly only; no React-tree UI change).
  • pnpm validate-i18n — parity green across all 62 locales (120 entries added across 60 non-en non-vi locales, exactly 2 keys × 60 locales as expected).

2026-06-22 — Per-decision Share button on /intel/decisions (F11 × F13 × F59)

Type: Feature (growth · distribution-object placement)

Summary:

  • The atomic-share affordance shipped to TodayPage (#398) and to /intel/risks (#419) is now also on every open decision card in /intel/decisions, the triage queue CEOs actually walk during morning planning.
  • Same useDecisionShare hook, same buildDecisionShareText artifact, same en/vi locale keys (already shipped) — no new helpers, no new locale strings, no API change.
  • Same gate as TodayPage: button visible only when status === 'open' (decided / cancelled never expose Share — they are not actionable forwards).

Why:

  • A CEO walking /intel/decisions to weigh a queue of open requests can now forward “@cofounder, decide this; your call by Friday” with one tap, instead of navigating back to Today (friction → fewer forwards) or screenshotting/retyping into Zalo (silent loss of F59 attribution + Lumi branding).
  • Closes the intel-trio Share affordance audit for decisions; commitments still uses DataTable and is the next symmetric move on a different rendering pattern.

Verification:

  • pnpm test -- src/test/DecisionsPage.test.tsx — 18/18 green (3 new tests for the share affordance + the existing 15).
  • ./node_modules/.bin/tsc --noEmit clean.
  • node scripts/check-design-tokens.mjs clean (semantic tokens only).

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, Decisions were reviewed for edit/delete affordances. Decision: Decisions are evidence-bound items (each links an EvidenceSource); their UI lifecycle is decide / cancel, 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 decision body copy

Type: UX Fix

Summary:

  • Some decision cards repeated the exact same sentence as both the title and the description, which made the queue feel noisier without adding decision context.
  • Decision cards now suppress the description when it duplicates the title, while keeping options, requester, deadline, and money context intact.

Verification:

  • frontend/screenshots/11-decisions/01-decisions-list.png
  • pnpm test -- src/test/DecisionsPage.test.tsx
  • pnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "11-decisions"
  • ./node_modules/.bin/tsc --noEmit

2026-06-18 — Visual QC: restore visible one-tap cancel action

Type: UX Fix

Summary:

  • The Decisions page had a wired cancelDecision mutation but no visible cancel action, despite F11 requiring one-tap cancel from the list.
  • Open decision rows now render both Quyết định and Hủy yêu cầu, so stale or offline-resolved requests can be cleared without opening another surface.

Verification:

  • frontend/screenshots/11-decisions/01-decisions-list.png
  • pnpm test -- src/test/DecisionsPage.test.tsx
  • pnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "11-decisions"

2026-06-15 — Frontend route namespaced

Frontend route namespaced: /decisions -> /intel/decisions (app-wide IA restructure; API paths unchanged).

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

Type: Documentation Change

Summary:

  • Reframed the §1 “Competitive differentiation vs Asana/Monday/ClickUp approvals” paragraph to “What we replace: ‘anh duyệt giúp em’ in Zalo and the boss’s memory,” using fit-out approval vocabulary (duyệt báo giá, đặt cọc, vật tư) — the contractor owns no approvals tool.
  • Replaced Asana / Monday / ClickUp comparisons across five §2 JTBD stories with the Zalo / Excel / boss’s-memory incumbent.
  • Added a §3 cross-link: a decision needing an external sign-off routes through an F71 confirmation link (Loop 2); F11 records the internal decision, F71 owns the external sign-off surface (sharpening the existing “external counterparty sign-off” Out boundary).

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; surface the F71 wedge where natural.

Impact:

  • Positioning copy only. No change to data model, API, acceptance criteria, error states, audit verdicts, or status.

Tests:

  • None (documentation change).

Notes:

  • Every product fact, AC, API row, and §11 audit verdict left intact; only competitive framing and one cross-link added.

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

Type: Documentation Change

Summary:

  • Added ## 2. User Stories & Acceptance Criteria heading (JTBD stories were embedded in §1b without a §2 heading).
  • Numbered “Current Behavior” as §8 (was unnumbered between §7 and §8 Diagrams).
  • §8 Diagrams → §10; §9 Implementation Audit → §11 (11-row table); §11 Edge Cases absorbed into §11 note; §12 Error States (ES1-ES15) → §9; §10 Open Questions → §12; §13 Bug Log stays.
  • §11 audit: 11 rows, 6 ✅, 5 ⚠️ (audit CRUD gap, empty decision_text drift, options_json validation, cross-tenant evidence, options never extracted).

2026-06-12 — Added Edge Cases (E1–E18) and Error States (ES1–ES15) sections

Type: Documentation Change

2026-06-12 — Verified §2 ACs against implementation

Type: Doc Fix / Verified

Summary:

  • decide/cancel guards confirmed (400 on non-open); options_json conditional render confirmed; cross-tenant test confirmed; brief priority escalation for overdue confirmed.

2026-06-10 — Decide/cancel guards and options rendering shipped

Type: Feature

Summary:

  • Added 400 guard on decide and cancel actions when status is not open.
  • options_json and recommended_option now render in DecisionsPage with null-safe conditional rendering.
  • Audit log wired for decide/cancel actions.

14. Success Metrics (Appendix)

  • Decision-extraction yield ≥ 0.15 per submitted pulse: decisions are rarer than risks (measured separately). A yield above 0.4 suggests over-extraction (F08 cap applies).
  • % of pending decisions with decision_needed_by ≤ today resolved within 24h > 70%: the Brief surfaces overdue decisions at the top; if the CEO acts on 70%+ within one day, the morning Brief is working.
  • Mean time to decide trending down over 4 weeks: lagging proof that earlier visibility leads to faster decision closure.