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.
- Given a web quick-capture box or Telegram
- 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.
- Given an inbox item, Lumi proposes zero or more structured suggestions (
- 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.
- Given an accepted suggestion, then the destination object is created with linked
- 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=cursortraverses tenant-scoped signals by descending UUIDv7id, preserves status/company/search filters, and avoids deep-pageOFFSET.
-
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_idnullable.
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
| Method | Path | Min role | Request | Response | Errors |
|---|---|---|---|---|---|
| GET | /api/signal-inbox/ | member | ?status=&company=&q=&pagination=page|cursor | inbox list; page default | 401 |
| POST | /api/signal-inbox/ | member | {title?, raw_text, company_id?, project_id?, deal_id?, capture_channel?} | created inbox item | 400 validation |
| GET | /api/signal-inbox/<id>/ | member | — | item + proposals | 404 |
| POST | /api/signal-inbox/<id>/extract/ | member | optional mode | proposals generated | 409 already reviewed |
| POST | /api/signal-proposals/<id>/accept/ | manager | optional edited payload | created destination object | 400 invalid payload |
| POST | /api/signal-proposals/<id>/reject/ | manager | {reason?} | rejected proposal | 409 already accepted |
| POST | /api/signal-inbox/<id>/ignore/ | member | — | ignored item | 409 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_signalaction. Confirming lands the member on the same/intel/inboxstaging 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
inboxnamespace, 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 workingerror_retrybutton callingrefetch(). 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 labelfield_text(“Signal text”).
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Capture via web creates inbox item + EvidenceSource | backend | new backend/tests/test_signals.py |
| 2 | Telegram /note capture creates same model path | backend | same + backend/tests/test_telegram.py |
| 3 | Extract proposals; zero-proposal case stays valid | backend | same |
| 4 | Accepting a proposal creates the destination object with linked evidence | backend | same |
| 5 | Rejecting a proposal records feedback calibration | backend | same |
| 6 | Cross-tenant access to inbox item, company, deal, or promoted object is blocked | backend | same |
| 7 | Capture → extract → accept flow in UI | e2e | new describe("Signal Inbox") |
| 8 | Page renders captured signals + provenance/channel badges | frontend | frontend/src/test/SignalInboxPage.test.tsx |
| 9 | Empty inbox shows the positive empty state, not the error state | frontend | same |
| 10 | Load failure shows inline error + working retry — never the false-empty | frontend | same |
| 11 | Whitespace-only capture warns with the descriptive error_empty_text copy | frontend | same |
| 12 | Capture / extract / ignore / accept / reject actions call the right API | frontend | same |
| 13 | Status tabs re-query with the selected status param | frontend | same |
| 14 | capture_signal action proposes, confirms, and creates a staged inbox item with chat evidence | backend | backend/tests/assistant/test_assistant_actions_core.py, test_assistant_actions_parity.py |
| 15 | A capture action from another tenant cannot be confirmed | backend | backend/tests/assistant/test_assistant_actions_permissions.py |
| 16 | Explicit “save signal” chat turn shows a confirmable action and reaches /intel/inbox | e2e | frontend/tests/e2e/inbox.spec.ts |
| 17 | Page compatibility, cursor traversal, tenant isolation, filter retention, no SQL OFFSET, index | backend | tests/core/test_high_value_cursor_pagination.py |
| 18 | An initially empty Android inbox accepts pull-to-refresh and renders a signal created while the screen is open | e2e | .maestro/critical/95_signal_inbox_empty_refresh.yaml |
| 19 | A mobile card body opens Review and left swipe requests confirmation without ignoring directly | source + e2e | SignalInboxSourceTest, .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 barefield_textlabel; backend: 400 on emptyraw_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 loadinbox.error_invalid_context— linked company/project/deal is not in-org or incompatibleinbox.error_already_reviewed— extraction re-run blocked after review unless explicitly reopenedinbox.error_accept_failed— destination object creation failed; proposal remainsproposed- Telegram
/notewithout 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"orbrief_itemequivalent decision recorded in implementing PR)
| # | Error | User message | HTTP | Logged | Notes |
|---|---|---|---|---|---|
| ES1 | Cross-tenant proposal access — user of org A tries to accept/reject org B’s proposal. | Standard 404 | 404 | No | SignalProposalViewSet queryset filters via inbox_item__organization. |
| ES2 | Extract on already-reviewed item — user calls extract on an item whose status is not captured. | inbox.error_already_reviewed | 409 | No | extract_proposals() raises ValueError("already_reviewed"). |
| ES3 | Accept on already-accepted proposal — user double-clicks accept or two users race. | inbox.error_already_accepted | 409 | No | accept_proposal() raises ValueError("already_accepted"). |
| ES4 | Promote → ignore on already-promoted item — user calls ignore on an item that was already promoted. | inbox.error_already_promoted | 409 | No | ignore_signal() raises ValueError("already_promoted"). |
| ES5 | Non-manager tries to accept proposal — member (not manager/admin/owner) calls POST /api/signal-proposals/<id>/accept/. | inbox.error_not_permitted | 403 | No | Gated by IsOrgManager permission class on SignalProposalViewSet. |
| ES6 | Capture with nonexistent context — POST /api/signal-inbox/ with company_id pointing to a company in a different org. | Standard 400 | 400 | No | SignalInboxItemSerializer 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
signalsER diagram - frontend/DIAGRAMS.md
/intel/inboxroute - 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.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | apps/signals Django app | ✅ match | backend/apps/signals/ |
| 2 | /api/signal-inbox/ + /api/signal-proposals/ endpoints | ✅ match | backend/apps/signals/views.py |
| 3 | Backend tests test_signals.py | ✅ match | backend/tests/test_signals.py |
| 4 | SignalInboxPage.tsx + /intel/inbox route | ✅ match | frontend/src/pages/intel/SignalInboxPage.tsx |
| 5 | E2E: inbox.spec.ts | ✅ match | frontend/tests/e2e/inbox.spec.ts |
Constraints already present in the codebase:
EvidenceSourcealready supportsmanual_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-captureis not Signal Inbox parity: it returns extraction proposals and records chat pulse equivalence, but does not createSignalInboxItem. F32 therefore uses the separate ceiling-0capture_signalaction.
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.ktpreviously routed"Signal Inbox"→TodayNavKey(silent dead-end). Now routes toSignalInboxNavKey→SignalInboxScreen. - iOS native screen replaced.
IntelligenceInboxScreens.swiftnative SwiftUI implementation removed; replaced by KMP bridge viaSharedScreenBridge.swift → .signalInbox → makeSignalInboxViewController. - New shared files:
SignalInboxRepository.kt,SignalInboxScreenModel.kt,SignalInboxScreen.ktincommonMain; 18-testSignalInboxScreenModelTest.ktincommonTest. - API client: 5 new methods in
LumiApiClient.kt; 2 new DTOs inLumiDtos.kt. - i18n: 29
signal_inbox_*keys in bothvalues/strings.xml(EN) andvalues-vi/strings.xml(VI). - Review UX:
AlertDialog-based review flow (no push navigation — iOS bridges per-screen); fetches fresh detail (with proposals) on open. OptimisticactedProposalIds/ignoredItemIdssets 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.
SignalCardreceivedonIgnorebut 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-scrollableLumiEmptyState. 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.
SignalInboxSourceTestprotects 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
8dc8dbf9672a1366f4528e031a605b25c065d6depassed the populated Signal Inbox flow 1/1 in 28 seconds on the guardedLumi_Pixel7_API35emulator 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.mdis 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/chatrun-capturedoes not createSignalInboxItem; it returns proposals and writes only chat pulse equivalence. A newcapture_signalaction is therefore required. - Added the confirm-only
capture-signaltool/action. It reusesapps.signals.services.capture_signal, preserves the exact raw note andEvidenceSource, and returns/intel/inboxafter 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
connectorandemailto thecapture_channelenum —CHANNEL_CHOICESinbackend/apps/signals/models.pyhas four values (web,telegram,connector,email); the spec had documented only two. - §11 Row 4: Corrected
SignalInboxPage.tsxpath fromfrontend/src/pages/SignalInboxPage.tsx(old pre-IA-restructure location, no longer exists) tofrontend/src/pages/intel/SignalInboxPage.tsx(verified byls).
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.
SignalInboxPagenow consumesisError/refetchfromuseSignalInboxListand renders an honest inline error (TriangleAlert+error_title/error_load+error_retrybutton) 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_textinstead of the bare field labelfield_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 BehaviorandImplementation Audittext 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.pyandfrontend/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.pyand E2Edescribe("Signal Inbox").
Notes:
- v1 is text-only and confirmation-gated by design.