Derived from the canonical F32 CUE contract: **partial**.

Spec: F32 — Signal Inbox

Status: Derived from the canonical F32 CUE contract: partial. See the generated traceability view and gap view; Markdown does not carry an editable completion claim.

1. Problem & CEO Job

See clearly + trust the signal. Daily Pulse (F07) is scheduled; business pain is not. An angry customer message, changed supplier terms, or a post-meeting voice-note transcript matters before tomorrow’s pulse. F32 provides low-friction raw-text capture without forcing a tool migration.

Lumi need not replace Zalo, email, or meeting notes on day one. It turns important fragments into normalized, promotable operating-queue input.

2. User Stories & Acceptance Criteria

  • As a CEO or member, I want to drop raw business text into Lumi in seconds, so that important signals are not trapped in other tools.
    • Given a web quick-capture box or Telegram /note, when I paste text, then Lumi stores it as an inbox item with provenance and timestamp.
    • Given optional context (company/project/deal), when I include it, then the inbox item retains that linkage.
  • As a CEO, I want Lumi to suggest structured follow-on objects, so that I do not hand-copy one chat into three systems.
    • Given an inbox item, Lumi proposes zero or more structured suggestions (task, commitment, risk, decision, approval) with honest confidence.
    • No suggestion becomes a live operational object until a human accepts it. The inbox is a staging lane, not a hidden mutation engine.
  • As a manager, I want to promote one suggestion in one tap, so that raw notes become tracked work immediately.
    • Given an accepted suggestion, then the destination object is created with linked EvidenceSource, creator, and context carried over.
    • Given a rejected suggestion, then Lumi records the rejection as calibration data (F15), not as silent disappearance.
  • As a CEO, I want this to improve the Brief instead of flooding it, so that extra capture increases relevance rather than noise.
    • Only accepted suggestions enter the operational models that feed Brief/Today.
    • Inbox items can remain archived-only evidence if they matter historically but not operationally.
  • As a product, I want zero forced integrations, so that the wedge stays cheap to adopt.
    • v1 works from paste/forwarded text only. No OAuth email sync, no screenshot OCR, no auto-import bots.
  • As a client processing a long signal queue, I want cursor traversal that remains stable as new signals arrive.
    • pagination=cursor traverses tenant-scoped signals by descending UUIDv7 id, preserves status/company/search filters, and avoids deep-page OFFSET.

3. Scope Boundaries

  • In: web quick-capture, Telegram /note, inbox list/detail, structured suggestion generation, accept/reject actions, optional company/project/deal linkage, search over captured text, provenance.
  • Out: email account sync, OCR, file uploads, ambient scraping of chat apps, auto-creation of live records without confirmation, generic document management, meeting transcription pipeline.

4. Data Model

  • New app signals.
  • SignalInboxItem — org FK, created_by (OrganizationMember), capture_channel (web | telegram | connector | email), title, status (captured → reviewed | promoted | ignored), evidence_source FK (OneToOne), nullable company/project/deal FKs, captured_at, reviewed_at.
  • SignalProposal — inbox item FK, proposal_type (task | commitment | risk | decision | approval), payload_json, confidence_score, status (proposed → accepted | rejected), accepted_object_id nullable.

EvidenceSource stores the raw source text (source_type="manual_note" in v1) and remains the provenance anchor copied into any accepted downstream object.

5. API Contract

MethodPathMin roleRequestResponseErrors
GET/api/signal-inbox/member?status=&company=&q=&pagination=page|cursorinbox list; page default401
POST/api/signal-inbox/member{title?, raw_text, company_id?, project_id?, deal_id?, capture_channel?}created inbox item400 validation
GET/api/signal-inbox/<id>/member—item + proposals404
POST/api/signal-inbox/<id>/extract/memberoptional modeproposals generated409 already reviewed
POST/api/signal-proposals/<id>/accept/manageroptional edited payloadcreated destination object400 invalid payload
POST/api/signal-proposals/<id>/reject/manager{reason?}rejected proposal409 already accepted
POST/api/signal-inbox/<id>/ignore/member—ignored item409 already promoted

Conversational parity: the assistant capture-signal tool proposes a capture_signal action with the same raw_text/title/channel inputs. The member must confirm; the executor calls the shared capture_signal() service, so the form remains an optional direct path rather than a second implementation.

Tenant scoping: inbox items, linked companies, and accepted objects are all scoped to request.tenant. Proposal acceptance must create the destination object and attach the originating EvidenceSource in the same transaction.

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

6. UI / UX

  • New route: /intel/inbox.
  • Layout: capture composer at top, queue below, detail/proposal panel on selection.
  • Capture composer is intentionally plain:
    • title optional
    • one large textarea
    • optional context pickers
    • one submit button
  • The assistant is an equivalent direct path: “Lưu tín hiệu: …” creates a confirmable capture_signal action. Confirming lands the member on the same /intel/inbox staging lane; it does not extract or promote anything silently.
  • Proposal cards show:
    • suggestion type
    • title summary
    • confidence cue
    • primary CTA Tạo
    • secondary CTA Bỏ qua
  • On mobile, the complete non-terminal signal card opens Review. A left swipe may reveal Ignore, but it only requests the existing confirmation dialog and always snaps back; server state changes only after explicit confirmation. Promoted and ignored cards cannot be swiped.
  • Teaching empty state: “Dán ghi chú khách hàng, đoạn chat, hoặc tóm tắt cuộc họp vào đây. Lumi sẽ giúp biến nó thành tín hiệu hành động.”
  • i18n: new inbox namespace, en + vi.
  • Load-failure state (2026-06-21): a failed list load never renders the positive empty state. When the list query errors and the cache is empty, the page shows an honest inline error (TriangleAlert + error_title + error_load) with a working error_retry button calling refetch(). The error is gated on an empty cache, so a transient background-refetch failure never wipes already-loaded signals.
  • Capture validation copy (2026-06-21): the empty-text guard warns with a descriptive error_empty_text (“Add some signal text before capturing”) instead of the bare field label field_text (“Signal text”).

7. Test Plan

#CaseTypeWhere
1Capture via web creates inbox item + EvidenceSourcebackendnew backend/tests/test_signals.py
2Telegram /note capture creates same model pathbackendsame + backend/tests/test_telegram.py
3Extract proposals; zero-proposal case stays validbackendsame
4Accepting a proposal creates the destination object with linked evidencebackendsame
5Rejecting a proposal records feedback calibrationbackendsame
6Cross-tenant access to inbox item, company, deal, or promoted object is blockedbackendsame
7Capture → extract → accept flow in UIe2enew describe("Signal Inbox")
8Page renders captured signals + provenance/channel badgesfrontendfrontend/src/test/SignalInboxPage.test.tsx
9Empty inbox shows the positive empty state, not the error statefrontendsame
10Load failure shows inline error + working retry — never the false-emptyfrontendsame
11Whitespace-only capture warns with the descriptive error_empty_text copyfrontendsame
12Capture / extract / ignore / accept / reject actions call the right APIfrontendsame
13Status tabs re-query with the selected status paramfrontendsame
14capture_signal action proposes, confirms, and creates a staged inbox item with chat evidencebackendbackend/tests/assistant/test_assistant_actions_core.py, test_assistant_actions_parity.py
15A capture action from another tenant cannot be confirmedbackendbackend/tests/assistant/test_assistant_actions_permissions.py
16Explicit “save signal” chat turn shows a confirmable action and reaches /intel/inboxe2efrontend/tests/e2e/inbox.spec.ts
17Page compatibility, cursor traversal, tenant isolation, filter retention, no SQL OFFSET, indexbackendtests/core/test_high_value_cursor_pagination.py
18An initially empty Android inbox accepts pull-to-refresh and renders a signal created while the screen is opene2e.maestro/critical/95_signal_inbox_empty_refresh.yaml
19A mobile card body opens Review and left swipe requests confirmation without ignoring directlysource + e2eSignalInboxSourceTest, .maestro/critical/106_signal_inbox_row_actions.yaml

8. Current Behavior

The dedicated staging lane is shipped. apps/signals stores raw captured text as SignalInboxItem, generates SignalProposal suggestions, supports accept/reject/ignore flows with provenance, reuses F08 extraction, and exposes the workflow at /intel/inbox plus Telegram /note. The assistant’s confirm-only capture_signal action now calls the same capture service and stores EvidenceSource(source_type="chat_note"); run-capture remains extraction/proposal-only and is intentionally not treated as Signal Inbox capture.

9. Error States

  • inbox.error_empty_text — raw text missing (frontend: descriptive warning on a whitespace-only capture, replacing the bare field_text label; backend: 400 on empty raw_text)
  • inbox.error_title + inbox.error_load + inbox.error_retry — frontend list-load failure: inline error card with retry, shown only when the cache is empty so the positive empty state never masks a failed load
  • inbox.error_invalid_context — linked company/project/deal is not in-org or incompatible
  • inbox.error_already_reviewed — extraction re-run blocked after review unless explicitly reopened
  • inbox.error_accept_failed — destination object creation failed; proposal remains proposed
  • Telegram /note without payload:
    • bot prompts user to send the note text next
    • no inbox item created

Logging:

  • extraction failures log with inbox item id
  • accept failures log before rollback
  • rejected proposals record a Feedback row (target_type="signal_proposal" or brief_item equivalent decision recorded in implementing PR)
#ErrorUser messageHTTPLoggedNotes
ES1Cross-tenant proposal access — user of org A tries to accept/reject org B’s proposal.Standard 404404NoSignalProposalViewSet queryset filters via inbox_item__organization.
ES2Extract on already-reviewed item — user calls extract on an item whose status is not captured.inbox.error_already_reviewed409Noextract_proposals() raises ValueError("already_reviewed").
ES3Accept on already-accepted proposal — user double-clicks accept or two users race.inbox.error_already_accepted409Noaccept_proposal() raises ValueError("already_accepted").
ES4Promote → ignore on already-promoted item — user calls ignore on an item that was already promoted.inbox.error_already_promoted409Noignore_signal() raises ValueError("already_promoted").
ES5Non-manager tries to accept proposal — member (not manager/admin/owner) calls POST /api/signal-proposals/<id>/accept/.inbox.error_not_permitted403NoGated by IsOrgManager permission class on SignalProposalViewSet.
ES6Capture with nonexistent context — POST /api/signal-inbox/ with company_id pointing to a company in a different org.Standard 400400NoSignalInboxItemSerializer validates context entities belong to current org via validate_company()/validate_project()/validate_deal().

10. Diagrams & Docs Touched

  • DIAGRAMS.md ingress pipeline: external note → inbox → accepted signal → Brief
  • backend/DIAGRAMS.md signals ER diagram
  • frontend/DIAGRAMS.md /intel/inbox route
  • F07 / F08 / F12 / F21 / F27 specs for provenance and context glue

11. Implementation Audit (2026-06-11)

Edge Cases Catalog (formerly §13, 2026-06-12; 8 items): mark-all-read with 0 items (F32 uses status transitions not read flags — not applicable), page-based pagination under deletion (closed for cursor clients; page mode remains compatible), inbox item deleted before review (404 on stale link), notification for deactivated member (row persists, user cannot log in), cross-tenant inbox access (tenant filter enforced), empty proposal list after extraction (item → reviewed with empty proposals), accept creates duplicate commitment (by design — human judgment required), concurrent accept + reject (atomic DB check raises already_accepted 409). Full catalog in git history.

#ClaimStatusEvidence
1apps/signals Django app✅ matchbackend/apps/signals/
2/api/signal-inbox/ + /api/signal-proposals/ endpoints✅ matchbackend/apps/signals/views.py
3Backend tests test_signals.py✅ matchbackend/tests/test_signals.py
4SignalInboxPage.tsx + /intel/inbox route✅ matchfrontend/src/pages/intel/SignalInboxPage.tsx
5E2E: inbox.spec.ts✅ matchfrontend/tests/e2e/inbox.spec.ts

Constraints already present in the codebase:

  • EvidenceSource already supports manual_note; use it before inventing a second provenance system.
  • F08 extraction already knows how to create commitments/risks/decisions from text; F32 must reuse that logic, not fork it.
  • F27 company resolution is already the right context graph; inbox capture should link into it, not invent a second customer reference system.
  • run-capture is not Signal Inbox parity: it returns extraction proposals and records chat pulse equivalence, but does not create SignalInboxItem. F32 therefore uses the separate ceiling-0 capture_signal action.

Design decision locked:

  • Human-confirmed promotion only. Auto-creating live objects from arbitrary pasted text is how a wedge becomes noise.

12. Open Questions / Decisions

  • Should voice-note transcription be part of F32 or a later companion feature? Recommendation: later. Keep F32 text-only until the manual note loop proves demand.
  • Should accepted proposals be editable before creation? Recommendation: yes for title/description/owner fields, but keep the edit surface inline on the proposal card, not a full form detour.

2026-06-23 — Android + iOS KMP migration (mobile shared core)

Type: Mobile Platform Change — pull surface from native iOS shell into shared KMP commonMain.

Summary:

  • Android no-op eliminated. LumiApp.kt previously routed "Signal Inbox" → TodayNavKey (silent dead-end). Now routes to SignalInboxNavKey → SignalInboxScreen.
  • iOS native screen replaced. IntelligenceInboxScreens.swift native SwiftUI implementation removed; replaced by KMP bridge via SharedScreenBridge.swift → .signalInbox → makeSignalInboxViewController.
  • New shared files: SignalInboxRepository.kt, SignalInboxScreenModel.kt, SignalInboxScreen.kt in commonMain; 18-test SignalInboxScreenModelTest.kt in commonTest.
  • API client: 5 new methods in LumiApiClient.kt; 2 new DTOs in LumiDtos.kt.
  • i18n: 29 signal_inbox_* keys in both values/strings.xml (EN) and values-vi/strings.xml (VI).
  • Review UX: AlertDialog-based review flow (no push navigation — iOS bridges per-screen); fetches fresh detail (with proposals) on open. Optimistic actedProposalIds / ignoredItemIds sets for instant feedback.
  • See improvement doc: docs/improvements/2026-06/week-04/f32-signal-inbox-kmp.md.

13. Bug / Change Log

2026-07-20 — Mobile rows own review and confirm-before-ignore gestures

Type: P1 mobile interaction repair

Observed failure:

  • Review was confined to a small inline label instead of the complete signal row.
  • SignalCard received onIgnore but never invoked it, so the list exposed no swipe-to-ignore path.

Resolution:

  • Non-terminal cards are full-row Review targets with button semantics.
  • End-to-start swipe reveals Ignore, snaps back, and opens the existing confirmation. It never mutates on gesture completion; terminal cards cannot swipe.
  • Critical flow 106 uses one disposable captured signal to prove row tap and swipe confirmation on the exact installed APK. Named-device and Apple runtime evidence remain open.

2026-07-19 — Empty-state pull-to-refresh must remain interactive

Type: P1 mobile interaction repair

Observed failure:

  • The shared Signal Inbox wrapped its content in LumiRefreshable, but the empty branch rendered a non-scrollable LumiEmptyState. Material pull-to-refresh receives drag input through nested scrolling, so the ordinary downward gesture emitted no refresh request while the inbox was empty.
  • A signal created after initial load remained invisible until the screen model was recreated.

Resolution:

  • The empty branch now owns a full-height scroll state, preserving the nested-scroll path without adding another button or changing the visual hierarchy.
  • SignalInboxSourceTest protects the source invariant. Critical flow 95 starts from a disposable empty tenant, creates a real signal through the API while the screen remains open, swipes down, and asserts that exact signal on the installed APK.

Proof boundary:

  • The commit-bound Pixel 7 API 35 emulator journey passed with built and installed APK SHA-256 845655e7ff9b2016212f245202b63e8440f55e653c5c995888ce9ecffe924652.
  • Named Android floor-device, iPhone 12, iOS, and macOS runtime evidence remain open. The emulator result does not close those gates.

2026-07-16 — INT-08: require locale-independent Android route selectors

Type: Mobile verification correction

Observed failure:

  • A commit-bound Android emulator run reached the authenticated Vietnamese shell but failed before Signal Inbox because the reused navigation flow searched for the English display text Workspace.

Contract:

  • The Android Signal Inbox journey must enter Workspace, select the Signal Inbox row, and identify the destination with stable Compose test tags. Translated display text is presentation, not a navigation selector.
  • This correction can establish emulator UI evidence only. Named Android floor device, iPhone 12, and PM scoring remain open.

Result:

  • Commit 8dc8dbf9672a1366f4528e031a605b25c065d6de passed the populated Signal Inbox flow 1/1 in 28 seconds on the guarded Lumi_Pixel7_API35 emulator while the authenticated shell rendered Vietnamese.
  • The built and installed APK SHA-256 matched at f3a1ca2b8e96a98e0bcea2eeaad6a1246db043afbcb23674cbd767b12007fa51. Durable evidence is in /Users/james/.maestro/tests/2026-07-16_163846_int08.
  • The run inspected the populated card, proposal type, confidence, duplicate warning, and review actions without accepting, creating, dismissing, or ignoring anything. It does not prove mutation behavior or satisfy the named Android floor-device, iPhone 12, or PM-owned scoring gates.

2026-07-16 — INT-08: add populated Android parity selectors and fixture

Type: Mobile verification hardening

Summary:

  • Added stable shared Compose selectors for the Signal Inbox screen, populated card, proposal count, review action, proposal type/confidence, and conditional possible-duplicate warning.
  • Added .maestro/critical/65_signal_inbox_populated.yaml, which reuses the existing navigation flow and asserts the current demo payload’s content title, proposal metadata, F100 task label, and F99 duplicate warning.

Honest gate:

  • The flow is emulator UI evidence only. Named Android floor-device, iPhone 12, and PM scoring remain open; docs/tests/mobile-screen-audit.md is unchanged.

2026-07-13 — Additive UUIDv7 cursor pagination

Type: Performance + API compatibility

Added pagination=cursor to the signal inbox with tenant-scoped -id traversal and a matching (organization, -id) index. Status, company, and search filters survive cursor links; page mode remains the default. See docs/test-plan/high-value-cursor-pagination.md.

2026-07-13 — Assistant-first capture parity (P33-10 PR-A)

Type: Form-parity implementation

Summary:

  • Static code walk resolved the audit question: /intel/chat run-capture does not create SignalInboxItem; it returns proposals and writes only chat pulse equivalence. A new capture_signal action is therefore required.
  • Added the confirm-only capture-signal tool/action. It reuses apps.signals.services.capture_signal, preserves the exact raw note and EvidenceSource, and returns /intel/inbox after confirmation.
  • Added parity, executor, cross-tenant, mock-provider, and conversational E2E coverage. The existing web form remains unchanged and optional.

2026-07-14 — Contract Graph migration

Type: Contract ownership and proof reconciliation

Summary:

  • Added canonical F32 ownership for the seven Signal Inbox/proposal API operations, confirm-only assistant capture, and shared Android/iOS/macOS source paths.
  • Collected backend API/service/assistant proofs and kept browser plus named-platform runtime evidence explicitly blocking.

2026-07-02 — §4 channel enum and §11 Row 4 path corrected (batch 12)

Type: Documentation Truth Fix

Summary:

  • §4 Data Model: Added connector and email to the capture_channel enum — CHANNEL_CHOICES in backend/apps/signals/models.py has four values (web, telegram, connector, email); the spec had documented only two.
  • §11 Row 4: Corrected SignalInboxPage.tsx path from frontend/src/pages/SignalInboxPage.tsx (old pre-IA-restructure location, no longer exists) to frontend/src/pages/intel/SignalInboxPage.tsx (verified by ls).

2026-07-02 — Spec drift fix: check shipped ACs against §11 audit

Type: Documentation Truth Fix

Summary:

  • Checked all 9 previously open [ ] ACs in §2 that §11 confirms as implemented:
    • AC1 (web/Telegram capture → inbox item with provenance) — rows 1, 2 (apps/signals + /api/signal-inbox/)
    • AC2 (optional context linkage retained) — rows 1, 2 (SignalInboxItem company/project/deal FKs)
    • AC3 (structured proposals with confidence score) — rows 1, 2 (/api/signal-proposals/)
    • AC4 (no auto-promotion — human accept required) — rows 2 (accept/reject endpoints enforce human gate)
    • AC5 (accepted → destination object + EvidenceSource) — rows 1, 2 (accept endpoint + provenance)
    • AC6 (rejected → calibration data via Feedback row) — row 3 (test_signals.py covers test case 5)
    • AC7 (only accepted enter operational models) — rows 1, 2 (design: accept endpoint is sole promotion path)
    • AC8 (archived-only via ignore endpoint) — row 2 (/api/signal-inbox//ignore/)
    • AC9 (v1 text-only, no OAuth/OCR) — rows 1–5 confirm text-only implementation; scope exclusion holds

2026-06-21 — Kill load-failure false-empty + fill page-test gap (POLISH UX 7→9, QA 7→9)

Type: UX + QA Convergence (frontend-only; no API, route, or success-path behavior change)

Summary:

  • Load-failure false-empty fixed. SignalInboxPage now consumes isError/refetch from useSignalInboxList and renders an honest inline error (TriangleAlert + error_title/error_load + error_retry button) instead of the positive empty state when the list fails. Gated on an empty cache so a transient background-refetch failure never wipes loaded signals. Same pattern shipped on F23 (Horizon) and F14 (Assistant). See §6, §9.
  • Capture warning copy. Empty/whitespace-only capture warns with the descriptive inbox.error_empty_text instead of the bare field label field_text (audit Remaining Issue #8).
  • New i18n keys (en+vi, propagated to all locales): error_title, error_retry, error_empty_text.
  • Page test added. frontend/src/test/SignalInboxPage.test.tsx — 9 tests covering render, empty vs error states, error+retry recovery, whitespace-capture warning, capture/extract/ignore/accept/reject actions, and status-tab re-query (§7 rows 8–13).

2026-06-15 — Frontend route namespaced

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

2026-06-14 — Spec restructured to TEMPLATE.md canonical order (§1–§13)

Type: Documentation Change

Summary:

  • §13 Edge Cases (8 items, E1–E8) absorbed into §11 Implementation Audit EC note.
  • §14 Error States additional (6 rows, ES1–ES6) merged into §9 Error States.
  • §15 Bug / Change Log → §13.

Reason:

  • Align with TEMPLATE.md canonical section order per SP fleet restructuring pass.

Impact:

  • Spec authors and future implementation agents.

2026-06-12 — Added Edge Cases + Error States sections

Type: Documentation Change

Summary:

  • Added §13 Edge Cases (E1–E8): mark-all-read with 0 items (not applicable — F32 uses status not read flags), cursor expiry (page-based pagination, no cursor), deleted-before-review, notification for non-member, cross-tenant access, empty proposals, duplicate creation on accept, concurrent accept/reject race.
  • Added §14 Error States (ES1–ES6): cross-tenant proposal, already-reviewed extract, already-accepted accept, already-promoted ignore, non-manager accept, nonexistent context.
  • Each case grounded in existing code paths and test coverage.

Reason:

  • Hardening spec completeness for implementation audits and future contributors.

Impact:

  • F32 spec now has documented edge cases with test-coverage annotations.

Tests:

  • No code changes. Test gaps noted per row.

2026-06-12 — Spec sync after ship audit

Type: Doc Fix

Summary:

  • Replaced stale pre-ship Current Behavior and Implementation Audit text with the actual shipped F32 state.

Reason:

  • The feature had already shipped, but the spec still said there was no inbox and “Not built.”

Impact:

  • Registry, spec, tests, and implementation now tell the same story.

Tests:

  • No code changes. Verification comes from backend/tests/test_signals.py and frontend/tests/e2e/inbox.spec.ts.

Notes:

  • The product contract is unchanged; this only corrects documentation drift.

2026-06-11 — Initial spec

Type: Feature

Summary:

  • Added the Signal Inbox spec as Lumi’s low-friction capture lane for ad-hoc external business signals.

Reason:

  • Daily Pulse is scheduled; real business pain is not. Lumi needs a staging lane for pasted/forwarded text before it can own the control loop.

Impact:

  • Defines the inbox, proposal, and evidence-promotion contract.

Tests:

  • Planned in backend/tests/test_signals.py and E2E describe("Signal Inbox").

Notes:

  • v1 is text-only and confirmation-gated by design.