Spec: F92 — @lumi in Messaging (Lumi as a Mentionable Team Member)
Extends F29 Internal Chat and F91 Lumi, the AI Chief of Staff. F92 brings F91’s private
/intel/chatassistant loop to team conversations: anyone can invoke its first-party@lumiteammate. It adds neither a new agent nor a bot platform.
Status: partial — backend P1/P2, web ActionCard, shared KMP action state, and iOS/Android top-rail access exist; browser, named-device, live-provider, production, and mobile IA evidence remain open
1. Problem & CEO Job
F92 serves all three CEO jobs, chiefly by trusting signal where the team talks. F91 provides a
private chief of staff at /intel/chat, while operational work happens in F29 channels, topics, and
DMs. Asking a question or turning a request into a task currently requires leaving the thread and
retyping context, which suppresses answers and capture.
Anyone can @lumi in the active conversation and receive grounded facts or confirm-before-commit
actions there. This applies the assistant-first doctrine to teams without another surface; otherwise
Lumi remains a single-player tool in a multiplayer company.
2. User Stories & Acceptance Criteria
Binding constraints (non-negotiable, stated before the stories because every story is subordinate to them):
- B1 — Trust line honored, not bent (F29 §1). F29’s non-negotiable: “Lumi’s AI does
not ambiently read chat… Chat content reaches the AI pipeline only when a human
explicitly flags a message/topic as signal.”
@lumiis that explicit human invocation — the same consent principle as flag-as-signal. Lumi reads only the invoking message plus the minimal same-topic context needed to answer, and only because a human invoked it there. No ambient monitoring, ever. A message that does not@lumiis never seen by the AI pipeline. - B2 — One first-party teammate, not a bot platform (F29 §3 “Out (hard)”). F29
explicitly rejects “integrations/webhooks/bot platform.”
@lumiis one first-party AI teammate, not a generic bot API. No webhooks, no third-party bots, no registration of arbitrary automated members. - B3 — Faithfulness (F86 / F91). @lumi’s channel replies pass the same grounding gate as every other AI output — evidence-cited or not shown. Per the Constitution: “There is no ‘Lumi thinks’ without a receipt.” Zero invented facts.
- B4 — Trust ladder + Constitution ceiling (F91 Pillar B / Constitution §“Act beyond
what you granted”). Actions Lumi proposes from a thread are confirm-before-commit.
Per the Constitution, verbatim: “External messages sent to other people, anything
involving money, and any deletion stay at propose-and-confirm permanently — at every
autonomy level, with no exceptions and no future path to change this.” The confirmer is
the member who
@lumi-mentioned (the actor); Lumi acts with that member’s role-filtered permissions — a viewer gets read answers, not privileged writes. Others in the channel see “Lumi proposed X — pending <name>‘s confirm.” - B5 — Tenant isolation (F91 invariant 6). Lumi in org A’s channel sees only org A’s data. The agent loop gets tenant-scoped tools, never raw ORM access; it cannot name another tenant.
- B6 — Restraint (F91 Bet 4 / F36). Lumi replies once per explicit mention, in-thread. No unsolicited follow-ups, no proactive channel posts (that is a separate feature). Push about the reply respects quiet hours / cooldowns / opt-outs.
Stories:
- As a member, I want to ask Lumi a question inside the thread I’m already in, so
that I get a grounded answer without leaving the conversation.
- Given a channel message
@lumi what's our cash position?, when posted, thenparse_mentionsresolves@lumito the org’s system Lumi member, the F91 agent loop runs scoped to that channel’s org, and Lumi posts exactly one replyMessagein the same channel + topic with evidence chips (B3). - Given the invoking member is a viewer, when they
@lumi tạo task…, then Lumi’s toolbelt is filtered to that viewer’s role — a write it cannot perform is refused or downgraded to a read answer, identical to the form-path 403 (B4).
- Given a channel message
- As a member, I want to turn a line of conversation into a real item, so that
work captured in chat doesn’t get lost.
- Given
@lumi tạo task giao Minh sửa máy bơm, when processed, then Lumi replies with a ProposalCard / ActionCard (reused from F91) — nothing is written until the mentioner confirms; the card shows the evidence span + honest confidence (B3). - Given a proposed money / external / delete action, when surfaced, then it is
autonomy_level_used = 0(confirm-only) permanently — no autonomy grant can raise it (B4, Constitution §3). - Given the proposal is pending, when other channel members view the thread, then they see “Lumi proposed X — pending <mentioner>‘s confirm”; only the mentioner can confirm/reject (B4).
- Given
- As a member who never
@lumis, I want confidence Lumi isn’t reading my chat, so that the room stays honest.- Given a channel with ordinary human messages and no
@lumimention, when they are posted, then no agent loop runs, no Lumi reply appears, and no chat content enters the AI pipeline (B1 — the trust-line test).
- Given a channel with ordinary human messages and no
- As a manager, I want Lumi discoverable, so that the team learns to use it.
- Given the composer mention-autocomplete, when a member types
@, then a Lumi entry (sparkle avatar, “AI teammate” label) appears alongside human members and inserts@lumi.
- Given the composer mention-autocomplete, when a member types
3. Scope Boundaries
- In:
@lumiin public and private channels + topics and in group DMs.- Resolution of the reserved
@lumihandle to a per-org system “Lumi” member (aChannelMember/ConversationMemberrow, distinct avatar, in mention autocomplete). - A grounded, evidence-cited in-thread reply from the Lumi system member, delivered over the existing F29 SSE stream.
- Confirm-before-commit actions proposed from a thread and confirmed by the mentioner, with the mentioner’s role-filtered permissions; money / external / delete stay ceiling 0 forever.
- Tenant scoping on every read and every tool call.
- Restraint: exactly one reply per mention; push respects quiet hours / cooldowns.
- Mobile nav promotion: Messages becomes a top-level slot on iOS (tab) and Android — it is now where the team talks to people and Lumi (see §6).
- Out (hard — each is a rabbit hole this spec refuses):
- Ambient chat reading — Lumi never reads a message it was not explicitly
@lumi’d into (B1). No “Lumi is watching this channel” mode. - Proactive / unsolicited Lumi channel posts — a mission that opens a channel turn is a separate feature; F92 is reply-only (B6).
- Third-party bot platform / webhooks / integrations — @lumi is one first-party teammate, not a bot API (B2; F29 “Out (hard)”).
- Lumi editing or reacting to others’ messages — Lumi only posts its own reply.
- Voice-in-channel — audio capture in threads is F87/F30 territory, not here.
- @lumi acting with anyone’s permissions but the mentioner’s — no privilege borrowing, no “ask Lumi as the CEO.”
- A second agent brain — F92 reuses the F91 loop; it never forks a messaging-specific reasoner (F91 Bet 4: “never builds a parallel brain”).
- Ambient chat reading — Lumi never reads a message it was not explicitly
4. Data Model
No new domain tables. F92 is a thin seam between the F29 messaging app and the F91
assistant loop. All touched models already extend UUIDModel + carry an organization FK,
and the F29 partition-readiness rules (§4.1 of F29) are preserved unchanged.
Additions / extensions:
- System “Lumi” member (per org). A single reserved
OrganizationMemberper organization, backed by a systemUser, representing Lumi as a participant. It is created idempotently (alongsideensure_default_channel()/ on first@lumi— see §12). It is auto-joined to channels/conversations as needed so it can be aMessage.senderand appear in mention autocomplete. It carries a distinct role/flag (e.g.is_system/ reserved handlelumi) so:parse_mentionscan resolve the reserved tokenlumito it (see below);- it is excluded from human-only aggregates (unread counts,
@channelfanout, headcount) where a real person is meant; - it can never be assigned work, invited, deactivated, or DM’d as a human (it is not the subject of F29 human flows).
Message.sender= Lumi. Reused as-is:Message.senderis a nullable FK toOrganizationMember, so a reply authored by the system Lumi member is an ordinaryMessagerow withsender = <lumi member>,channel/conversation+topicmatching the invoking message. No schema change.Message.mentions— reused as-is. The invoking message’smentionsJSONField includes the Lumi member id (strings of member UUIDs), so an@lumimention round-trips exactly like a human mention.MENTION_RE = re.compile(r"@([A-Za-z0-9_]+)")already matches@lumi;parse_mentionsgains a reserved-handle branch that maps the normalized tokenlumito the system member id (parallel to the existing@channelspecial case), so the handle cannot be shadowed by a human whose name normalizes to “lumi”.- Invoking message → agent trace / action link. A pointer from the invoking
Messageto the resulting F91ChatMessage.agent_trace_json/AssistantAction(s) it produced, so a thread reply is auditable back to what the loop did. Two candidate shapes (decide in §12 / an ADR — no new hot FK into themessaging_messagestore, per F29 §4.1 rule 2): (a) reuse the F91AssistantAction.source_messageby widening it to reference the messagingMessage, or (b) carry the linkage inagent_trace_jsonmetadata ({"origin": "messaging", "message_id": …, "channel_id": …, "topic": …}) and on the replyMessage(agent_trace_id). Preference: (b) — metadata, not a new FK — to keep the partition-ready message store free of long-lived inbound references. - F91 models reused unchanged:
AssistantAction(proposal/confirm/reject/executed state machine),AutonomyGrant(ceiling 0 for money/external/delete is enforced in the action registry, so no thread action can self-promote),EvidenceSource(grounding_verifiedviaassert_grounded).
State machine: Message still has none (F29). AssistantAction state machine is F91’s,
unchanged — F92 adds no state. ER / state diagrams: note the new
messaging.Message → assistant trace linkage in backend/DIAGRAMS.md; GLOSSARY gains a
“Lumi (system member) / @lumi” entry disambiguated from F14/F91 “Ask Lumi”.
5. API Contract
F92 opens no new send endpoint — it rides the F29 message-send path and the F91 action endpoints. The only genuinely new backend surface is the trigger seam and the reserved-handle resolution.
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| POST | /api/messages/channels/<id>/messages/ (F29, existing) | member of channel | {topic, body_md} where body_md contains @lumi | the posted Message (SSE fans out); async loop kicked off | 400 empty/no-topic; 403 non-member; 404 private/foreign |
| POST | /api/messages/conversations/<id>/messages/ (F29, existing) | participant | {body_md} with @lumi (group DM) | posted Message; async loop | 403 non-participant |
| GET | /api/messages/events/ (F29 SSE, existing) | member | — | stream now also carries Lumi’s reply Message + “Lumi is thinking” typing event for the member’s visible threads | — |
| POST | /api/assistant/actions/<id>/confirm/ (F91, existing) | the mentioner (own action) + underlying role check | {} or per-type edits | {status: "executed", result: {type, id, route}} | 403 role / not-the-mentioner; 409 not proposed; 410 expired |
| POST | /api/assistant/actions/<id>/reject/ (F91, existing) | the mentioner | {reason?} | {status: "rejected"} | 409 |
What is new (backend seam, not new HTTP surface):
-
parse_mentions(body_md, organization_id)gains a reserved-handle branch: when the token set containslumi, it resolves to the org’s system Lumi member id (and returns a flag, parallel to the existingchannel_mentionedreturn). This is the single trigger signal. -
post_message(messaging/services.py): after a channel/conversation message is created and mention-pushes are sent, if the Lumi member is inmentioned_ids, enqueue the async agent loop out of band (Celery, mirroring F29’s SSE push being best-effort inside atry/except— a loop-enqueue failure must never fail the human’s send). No synchronous AI work on the send path. -
A messaging-adapted agent entry (reuses F91
run_agent_loop_task): input = the invokingMessage.body_md; context = a bounded window of recent messages in that same topic (only same-topic, honoring B1 — how many is a §12 open question); acting member = the invoking message’ssender(role-filtered toolbelt via the samebuild_action_permission_filterthe/palette and confirm endpoint share, so role parity holds by construction); tenant = the channel/conversation’sorganization. -
The loop’s
FinalAnsweris written back as a newMessageauthored by the system Lumi member, same channel+topic (or conversation), grounded via the F91 streaming grounding gate before it is persisted/streamed. Proposals becomeAssistantActionrows (F91) rendered as ProposalCard/ActionCard in the reply, or a deep-link to/intel/chatfor confirm. Delivery = the existing F29 SSE fanout (per-user filtered — never broadcast then filter, per F29 §5). -
Tenant scoping: every read the loop performs goes through the F91 tenant-bound toolbelt constructed from the channel/conversation’s org; the loop cannot name another tenant (B5). The reply
Messageis created with that sameorganization_id. -
Evidence & confidence: the reply and every proposed action link an
EvidenceSourcewith a verbatim span; confidence stays in F86 bands (0.60–0.80 keyword, 0.85–0.90 LLM, never 1.0). A capability/meta question (“@lumi what can you do?”) routes to F91’s groundeddescribe-capabilitiestool (role-filtered), never a free-form guess. -
Confirm authority: the F91 confirm endpoint re-validates against the same permission class the form path uses and that the confirmer is the mentioner — parity by construction; autonomy confers no authority.
6. UI / UX
- Route(s): no new route. Lives inside F29’s
/hub/messages(three-pane desktop, two-level mobile) and the F29MessagesPanelCommand Dock pod. Confirm of a thread proposal may deep-link to/intel/chat(F91) when a full action view is warranted. - Composer:
@lumiappears in the existing F29 @mention autocomplete dropdown (added in the 2026-06-16 F29 pass) — a sparkle avatar + “AI teammate” sublabel, listed first or visually set apart from human members; selecting inserts@lumi. - Lumi’s reply is visually distinct: an AI-teammate badge on the message header
(not a normal human avatar), evidence chips (reuse the F29 flag/evidence chip
styling + F91 ProposalCard evidence-span expansion), and F91
ProposalCard/ActionCardinline for proposed actions. Others see the pending-confirm attribution line (“Lumi proposed X — pending <name>‘s confirm”). - “Lumi is thinking…” indicator during the ~2–6s loop latency — a lightweight typing affordance in the thread (delivered as an SSE event), replaced by the reply when it lands; on loop failure it resolves to a calm in-character error bubble (see §9), never a spinner that hangs.
- States to design: empty (no
@lumiyet — the autocomplete entry teaches it exists); loading (“Lumi is thinking”); error (loop failed / grounding fallback); populated (reply with evidence chips / proposal cards); no-permission (viewer mentioner — reply explains it can answer but not perform the write, mirroring the 403 path). - shadcn + tokens: shadcn components only; Lumi semantic design tokens only (Rule 4) —
no raw Tailwind palette. Reuse F91
ProposalCard/ActionCardand F29 message/chip components;check-design-tokens.mjsmust stay clean. - i18n:
messagesnamespace (F29) +docknamespace, en + vi together, no hardcoded strings (Rule 3). New keys:messages.lumi_teammate_label,messages.lumi_thinking,messages.lumi_ai_badge,messages.lumi_proposed_pending(with{{name}}),messages.lumi_error,messages.lumi_mention_hint. Propagate to all locales for key parity, as F29 does. - NAV (stated explicitly, part of this feature):
- Web: Messages is already beside Notifications in the CommandDock — no change.
- Mobile (iOS tabs / Android): promote Messages to a top-level slot as part of
F92. Rationale: with @lumi, Messages becomes the single place the team talks to both
people and Lumi, so it earns a primary nav position on the phone (where the wedge
actually lives). This is an intentional IA change scoped into F92, not a side effect —
update
android/+ iOS nav and [frontend/DIAGRAMS.md]/mobile nav diagrams accordingly.
- Assistant path (F91 doctrine): F92 is an assistant path — it is the whole point.
It adds no new form; every capability reachable via
@lumiis the F91 conversational toolbelt, now invocable from team threads. Form parity is inherited from F91 (the F91 parity test table already enumerates the tools). No new required form is introduced.
7. Test Plan
All rows planned (unbuilt) — nothing in this spec is built yet (§11).
| # | Case | Type | Where |
|---|---|---|---|
| 1 | @lumi resolves to the org’s system Lumi member (parse_mentions reserved-handle branch; cannot be shadowed by a human named “Lumi”; @channel unaffected) | backend | backend/tests/test_messaging.py::TestLumiMentionResolution (planned) |
| 2 | A channel message mentioning @lumi triggers exactly one grounded reply Message, authored by the Lumi member, in the same channel + topic | backend | backend/tests/test_assistant.py::TestLumiInThreadReply (planned) |
| 3 | Trust-line test (B1): a channel with human messages and no @lumi mention triggers no agent loop, no reply, no AI-pipeline read | backend | ::TestLumiTrustLine (planned) |
| 4 | Lumi’s reply passes the F86/F91 grounding gate — an invented figure/date/customer is dropped to the honest fallback, a grounded answer passes | backend | ::TestLumiReplyGrounding (planned) |
| 5 | A proposed money action from a thread stays autonomy_level_used = 0 (confirm-only forever); no AutonomyGrant can raise it | backend | ::TestLumiThreadActionCeiling (planned; mirrors F91 TestAutonomyCeiling) |
| 6 | Only the mentioner can confirm/reject the proposed action; a different channel member confirming → 403 | backend | ::TestLumiConfirmIsMentionerOnly (planned) |
| 7 | Tenant isolation (B5): an @lumi mention in org A’s channel never surfaces org B data; the reply Message is org-A scoped | backend | backend/tests/test_multitenancy.py::TestF92LumiMessagingTenantIsolation (planned) |
| 8 | Role-filtered toolbelt: a viewer mentioner gets a read-only capability set — a write tool is refused/downgraded identically to the form-path 403 | backend | ::TestLumiMentionerRoleParity (planned) |
| 9 | Restraint (B6): one reply per mention; two @lumi mentions → two replies, never an unsolicited follow-up; no proactive post; push honors quiet hours | backend | ::TestLumiRestraint (planned) |
| 10 | Reply + typing event are delivered only over the visible-thread SSE stream — never to a non-participant of a private channel/DM, never cross-tenant | backend | backend/tests/test_messaging.py::TestLumiSSEIsolation (planned; F29 §7 row 8 severity) |
| 11 | @lumi in group DM: reply lands in the conversation; non-participants excluded | backend | ::TestLumiGroupDM (planned) |
| 12 | E2E: member types @lumi … in a channel → autocomplete shows Lumi → “Lumi is thinking” → grounded reply with evidence chip appears without manual refresh; a proposed task shows a ProposalCard the mentioner confirms | e2e | frontend/tests/e2e/lumi.spec.ts describe("@lumi in messaging") (planned) |
Backend coverage must stay ≥97% (CLAUDE.md) / ≥99% (template); a new E2E describe block
is required. Source and focused test rows are implemented for P1/P2 and mobile source parity,
but runner and target-runtime evidence remains separate and blocking.
8. Current Behavior
Current behavior is partial, not draft-only. Today:
- F29 messaging is shipped: channels, topics, DMs/group DMs,
@namementions (parse_mentionsresolves human handles +@channel), SSE push, flag-as-signal. The repository now also has a reserved per-org system Lumi member, explicit@lumiresolution, and an async trigger seam for supported channels/group DMs. Ordinary messages still do not enter the AI pipeline. - F91 is built (P1–P6 + action suites): the agent loop (
run_agent_loop_task),AssistantActionconfirm/reject/undo, the trust ladder with hard ceilings, grounding gate, and tenant-scoped toolbelt all exist — but only reachable at/intel/chat, a private 1:1 surface. The F92 seam now reuses that loop from supported team-message mentions. The web renders the AI teammate, thinking state, grounded reply, and mentioner-only ActionCard. Shared KMP exposes Ask Lumi/action state, while iOS and Android currently provide top-rail Messages access. The spec’s promised mobile top-level slot is not yet proven or implemented as written.
9. Error States
| # | Error | User-visible response | Internal handling |
|---|---|---|---|
| ES1 | Agent loop enqueue fails on send | Human’s message still posts normally; no Lumi reply; no error shown to sender | Loop enqueue wrapped in try/except like F29’s _send_mention_pushes — a Lumi failure never fails the human send; logger.exception |
| ES2 | Loop runs but the model/answer is ungrounded | Lumi posts the honest grounding fallback (F91 _build_grounded_fallback), not an invented answer (B3) | Grounding gate replaces the answer before streaming; agent_trace_json["grounding"] records the decision |
| ES3 | Loop errors / times out | Calm in-character error bubble (messages.lumi_error), replacing the “thinking” indicator | Bounded loop (F91 hard step/time cap); no hung spinner; retried per F91 task policy if transient |
| ES4 | Viewer mentioner requests a privileged write | Lumi replies that it can answer but cannot perform that action for their role — same boundary as the form 403 | Toolbelt filtered by mentioner role; no AssistantAction created for a tool they can’t run |
| ES5 | Money/external/delete proposal | ProposalCard shown, confirm-only; no autonomous execution ever | Ceiling 0 enforced in the F91 action registry (B4) |
| ES6 | @lumi in an unsupported surface (e.g. a human 1:1 DM, if §12 decides to disallow) | Gentle nudge to /intel/chat, or handled — decision pending (§12) | See §12 open question |
| ES7 | Rapid repeated @lumi (spam) | Per-channel rate limit may throttle (§12); each honored mention still replies once | Restraint ledger / rate-limit policy — shape pending (§12) |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md— add themessaging.Message → assistant agent trace / AssistantActionlinkage; note the async trigger seam frompost_messageintorun_agent_loop_task.frontend/DIAGRAMS.md— no new web route; note the mobile nav promotion of Messages to a top-level slot (iOS tabs / Android).GLOSSARY.md— new entry “Lumi (system member) / @lumi”, disambiguated from F14 “Ask Lumi” and F91/intel/chat; note that@lumiis an explicit invocation, not ambient.- Depends on / cites: F29 §1 trust line, §3 Out-hard, F91 Five Bets / Pillar B / invariants, LUMI_CONSTITUTION.md §“Act beyond what you granted”.
- Likely a new ADR for the invoking-message → agent-trace linkage shape and the system Lumi member (see §12), since it’s a structural decision touching the partition-ready message store.
11. Implementation Audit (2026-07-16)
P1 (presence + grounded reply) — backend landed. P2 stage 1 (confirm-before-commit actions
from a thread) — backend landed (2026-07-05). P2 stage 2 (web ActionCard rendering +
pending-confirm attribution) — web landed (2026-07-05). Single-loop unification — backend
landed (2026-07-05): an @lumi turn now makes ONE grounded agent-loop pass whose FinalAnswer
IS the reply (the stage-1 redundant answer_chat_message call is removed). The mobile action
card remains. The registry row keeps its native-platform cells at their current state (the
confirmable card is web-only so far).
What P1 built (backend):
- System Lumi member.
OrganizationMember.is_system(new boolean,db_default=False; migrationorganizations/0020_f92_orgmember_is_system.py) marks the per-org reserved Lumi identity, backed by an inactive systemUserwith a reserved org-scoped email (lumi+<org_id>@system.lumi). Idempotent, lazy get-or-create viaapps/messaging/lumi_member.py::ensure_lumi_member(join-on-first-mention, §12 Q1 lean);get_lumi_memberreads without creating. - Aggregate exclusion (regression guard against the 2026-06-21 self-message/unread bug
class):
is_systemmembers are excluded from default-channel auto-join (ensure_default_channel), ordinary@handleresolution (parse_mentionsby_handle), and mention pushes (_send_mention_pushes). Lumi is never auto-joined as aChannelMember, so it never inflates headcount /@channelfanout; it posts as aMessage.senderwithout membership. @lumiresolution.parse_mentionsgains a reserved-handle branch (parallel to@channel) mapping the tokenlumito the org’s system member id; a human whose name normalises to “lumi” cannot shadow it. The resolved id lands inMessage.mentionsexactly like a human mention and is the single trigger signal.- Trigger seam (B1 trust line).
post_messagecalls_maybe_trigger_lumiafter mention pushes, wrapped intry/except(a Lumi failure never fails the human send, ES1). It enqueuesanswer_lumi_mention_taskonly when the Lumi member id is inmentioned_ids, only on channel or group-DM messages (1:1 human DMs out — §12 lean a), and never for Lumi’s own message (no loops, B6). answer_lumi_mention_task(apps/messaging/tasks.py). Loads the invoking Message (tenant-scoped), gathers a bounded same-topic context window (LUMI_CONTEXT_WINDOW = 10, same-topic / same-group-DM only — B1 minimal context), and builds the grounded answer via the reused vetted grounding pathapps.ai.services.answer_chat_message— the same org-scoped answer + F86summary_introduces_new_factfaithfulness gate as/intel/chat, which drops any invented fact to the deterministic grounded fallback (B3, zero invented facts). Acting member = the sender/mentioner (role parity, B4). Posts exactly one reply as the Lumi member in the same channel+topic (or conversation) and fans it out over the existing F29 per-recipient SSE stream. Reuse justification (B3):answer_chat_messageis the answer-only grounded path; the heavierrun_agent_loop_task(which mintsAssistantActionproposals) is reserved for P2 confirm-before-commit — P1 does not write actions, so reusing the lighter grounded path (not a parallel one) is the faithful minimum.
Tests (backend, tests/test_f92_lumi_messaging.py, 15 cases, all green): trust line
(B1 — no @lumi ⇒ no AI call, no reply, no member materialised), reserved-handle resolution
(no human shadow, @channel unaffected), one grounded reply in the same channel+topic, group
DM reply, 1:1-DM out-of-scope, grounding gate (B3 — invented amount/date replaced by safe
fallback), tenant isolation (B5 — answer + reply org-A scoped, org B untouched), restraint
(B6 — one reply per mention, no loop), mentioner-role parity (B4 P1 slice — sender is the
acting member), and aggregate exclusion (no auto-join, excluded from by_handle, no push to
Lumi). Full suite green at 97.14% coverage.
Mobile “Ask Lumi” affordance (shared KMP, shipped 2026-07-05). The shared Compose composer
(ui/messages/MessageThreadScreen.kt) now carries a leading one-tap “Ask Lumi” IconButton
(a ✨ sparkle glyph, LumiBlue-tinted, before LumiChatInputField). It calls
MessagesScreenModel.askLumi(), which idempotently prefixes the draft with the @lumi handle
(no-op if already present, case-insensitive), disabled while sending, with a localized
contentDescription and i18n key messages_ask_lumi (en/vi). This is the discoverable path to
P1’s literal-@lumi trigger without typing the handle — not a full @-mention autocomplete
picker (that remains deferred). Verified: iOS + Android compile, 3 new askLumi unit cases green.
P2 stage 1 (confirm-before-commit actions from a thread) — backend landed (2026-07-05).
An @lumi mention with an ACTION intent now proposes a confirm-before-commit
AssistantAction, routed through the same F91 agent-loop machinery /intel/chat uses —
so the grounding gate, the role-filtered toolbelt (tool_definitions(member=…)), the ceiling
registry (create_proposed_action), and the confirm/reject endpoints all apply identically,
never a parallel action path.
What P2 stage 1 built (backend):
_run_lumi_thread_turnseam (apps/messaging/tasks.py, renamed from_propose_thread_actionsby the 2026-07-05 unification). The invoking mention is run throughapps.chat.tasks.run_agent_loop_taskONCE on an ephemeral bridgeChatThread(owned by the mentioner, markedLUMI_MESSAGING_BRIDGE_TITLEand excluded from/intel/chatinapps.chat.views.list_threads), seeded with the bounded same-topic context + the mention (input_text— the same grounded input the single-shot reply used; any action’s evidence span is still G4-validated against this text). The loop builds the toolbelt withmember=<mentioner>(role filter) and mints proposals viacreate_proposed_action(ceiling registry). The seam returns both the loop’s groundedFinalAnswer(read from the bridge assistantChatMessage.contentbefore cleanup — this IS the reply) and the harvested action metadata. If the loop proposes nothing, the bridge rows are deleted (zero pollution) after the answer is read out; if it proposes, the thread is kept (theAssistantAction.threadFK depends on it). Best-effort — a loop failure never fails the reply, and an empty body degrades to theanswer_chat_messagesingle-shot fallback (ES1).- Trust invariants enforced by construction:
T1nothing auto-executes — every thread action isstatus="proposed",autonomy_level_used=0(no operational write until confirm).T2money/external/delete stay ceiling 0 forever — a@lumi tạo deal …proposes but never auto-executes and writes noDeal; guarded registry-wide by F91’stest_all_money_tools_are_confirm_only_forever.T3actor = the mentioner — the toolbelt is role-filtered to the mentioner (build_action_permission_filter), so a viewer is never offered a privileged write.T4the created action is confirmable by the mentioner via the existingPOST /api/assistant/actions/<id>/confirm/(executes tenant-scoped) and…/reject/; a non-mentioner confirming → 404 (member=request.membershipscope). - Action ↔ reply linkage = metadata, no new FK. The reply
Messagecarries the proposed actions in a new nullableMessage.metadata_jsoncolumn (migrationmessaging/0005_message_metadata_json.py) —{"origin":"messaging","source_message_id":…, "actions":[{action_id, action_type, title}]}— the spec §4 option (b) preferred shape; not a new inbound FK intomessaging_message(F29 §4.1 rule 2). Stage 2 renders ProposalCards from it. The reply’s proposal narration is the loop’s own groundedFinalAnswer(behind the F91 §11 proposal-narration honesty guard — a pending-confirm action can never read as “done”); the standalone_build_action_ackhelper was removed by the 2026-07-05 unification to avoid double-narration.
Tests (backend, tests/test_f92_lumi_actions.py, 9 cases, all green): T1 create-task
proposes without executing (+ reply metadata linkage); T2 money (create_deal) proposes,
ceiling-0, never writes a Deal; T3 viewer role-filter (privileged tools withheld); T4
mentioner confirm executes tenant-scoped, reject discards, non-mentioner → 404; T5 no @lumi
⇒ no action + org-A-only scoping. P1’s 15 cases stay green (answer-only turns still answer).
Full suite green at 97.12% coverage.
Stage-1 cost / single-loop unification — DONE (2026-07-05). Stage 1 ran the agent loop in addition to P1’s
answer_chat_message(two grounded model paths / ~3–4 Gemini calls per mention). This is now unified to a single agent-loop pass whose groundedFinalAnswerIS the reply:answer_lumi_mention_taskcalls_run_lumi_thread_turn(renamed from_propose_thread_actions), which runsrun_agent_loop_taskONCE and returns BOTH the loop’s grounded final answer (read from the bridge assistantChatMessage.contentbefore the no-action bridge cleanup) AND the harvested action metadata. The redundantanswer_chat_messageround-trip is removed — it survives only as a graceful fallback for the edge where the loop hard-fails and persists an empty body (ES1). One grounding path, one gate: the samesummary_introduces_new_factfallback that guarded the single-shot answer now guards the loop answer (an ungrounded scripted answer → the safe grounded fallback is what posts, B3). The three P1 tests that spied onanswer_chat_messagewere migrated to assert the loop is the reply source and thatanswer_chat_messageis not called (call-count 1→0); a newTestLumiSingleGroundingPassasserts the single grounded pass + the ES1 fallback. All F92 invariants preserved (B1/B3/B4/B5/B6 + T1–T6); the honest per-action ack (_build_action_ack) was dropped because the loop’s ownFinalAnswernarrates the proposal — no double-narration.
P2 stage 2 (web ActionCard rendering + pending-confirm attribution) — web landed (2026-07-05).
The confirm-before-commit actions stage 1 wrote into the Lumi reply’s metadata_json are now
exposed and rendered in the web chat thread, so the mentioner can confirm/reject in-thread.
What P2 stage 2 built (web):
- Serializer surface (
apps/messaging/serializers.py).MessageSerializergains three read-only fields, allnullon a normal message (backward-compatible, never leaks the bridge internals):lumi_actions— the minimal[{action_id, action_type, title}]list ActionCard renders from, sourced frommetadata_json.actions;lumi_action_actor_id— the mentioner’s member id (the only member the server lets confirm);lumi_action_actor_name— the mentioner’s name for the read-only pending line.tasks.pynow also writesmentioner_id+mentioner_nameinto the reply metadata for this (no new query at read time). - Thread render (
frontend/src/components/messaging/MessageItem.tsx). When a message carrieslumi_actions, one sharedActionCard(components/chat/ActionCard.tsx, reused as-is — confirm/reject wired to the existingassistantActionsApi.confirm/reject) renders per action below the message body, matching the assistant-thread styling. Money/delete/external stay confirm-only (ActionCard reflects the ceiling from the action type). - Pending-confirm attribution (B4). “Am I the mentioner” =
lumi_action_actor_id === myMemberId. The mentioner sees the actionable card; every other member sees a read-only pending line (“Lumi proposed <title> — pending <name>‘s confirmation”, i18nmessages.lumi_action_pending, en/vi + fallback name), no buttons — mirroring the server-enforced mentioner-only confirm (a non-mentioner confirm gets 404).
Tests: backend tests/test_f92_lumi_actions.py +2 cases (serializer exposes lumi_actions +
actor id/name on a Lumi action-reply message; all three fields null on a normal message).
Frontend src/test/connect/MessageItemLumiActions.test.tsx (3 cases): mentioner sees an actionable
ActionCard (confirm + reject present); a non-mentioner sees the read-only pending line (no buttons);
a message without lumi_actions renders normally. Full backend suite green at 97.12%; frontend
11669 unit tests green, 0 tsc errors.
Still pending (P2 stage 2 / P3): the mobile action card (KMP — a follow-up); live
action status sync on the rendered card (the metadata
freezes at proposed, so a card re-rendered after a prior confirm relies on ActionCard’s local
state / a re-confirm returns the endpoint’s 409/410 → “expired”) — a later enrichment; full
composer @lumi autocomplete/mention picker, AI-teammate badge, “Lumi is thinking” indicator
(frontend, P1 UI — deferred); mobile nav promotion (P3, shipped for iOS + Android top-bar).
Original spec-time audit (2026-07-04, pre-build)
Not built — this is a spec-only document. Nothing in F92 exists yet. The registry row
is 🔲 across all columns.
What the existing codebase makes easy (reuse, don’t rebuild):
MENTION_REalready matches@lumi;parse_mentionsalready has an@channelspecial-case branch to mirror for a reservedlumihandle.Message.senderis already a nullable member FK, andMessage.mentionsis a JSONField of member ids — a Lumi-authored reply and an@lumimention need no schema change to those fields.post_messagealready fires best-effort side effects (mention pushes) inside atry/exceptafter the message is created — the exact hook shape for enqueuing the async loop without risking the human send.- The F29 SSE stream already fans out new-message events per-visible-thread, per-user filtered — Lumi’s reply and the “thinking” event ride it unchanged.
- F91’s
run_agent_loop_task, tenant-scoped toolbelt,build_action_permission_filter(role parity),AssistantActionconfirm/reject, ceiling-0 registry, grounding gate, andProposalCard/ActionCardare all built and reusable — F92 is a seam, not a new engine.
What the existing codebase constrains (must design around):
- F29 §4.1 partition-readiness rule 2: nothing long-lived may hold an FK into the
messaging_messagestore exceptReaction. The invoking-message → agent-trace link must therefore be metadata (preferred) or a widenedAssistantAction.source_message, not a new inbound FK toMessage— an ADR decision (§12). - The system Lumi member must not pollute human aggregates: unread counts,
@channelfanout, headcount, and human DM flows must exclude it — otherwise it re-opens exactly the self-message/unread bugs F29 fixed (2026-06-21). Needs anis_systemguard threaded through those queries. - The trust line is load-bearing: the async trigger must fire only when the Lumi
member id is in
mentioned_ids— never on any other message. This is the single most important test (§7 row 3) and the thing a careless implementation would break first.
Build phases (high level, each independently shippable, in order):
- P1 — Presence + reply. System Lumi member (idempotent create);
parse_mentionsreserved-handle resolution; async trigger frompost_message; messaging-adapted agent entry (bounded same-topic context, mentioner as acting member, org tenant); grounded in-thread reply Message over SSE; composer autocomplete + AI-teammate badge + “thinking” indicator. Trust-line + tenant-isolation + one-reply-per-mention tests. - P2 — Confirm-before-commit actions from thread. Wire ProposalCard/ActionCard into the
reply; proposals become
AssistantActions confirmed by the mentioner with role-filtered permissions; money/external/delete stay ceiling 0; pending-confirm attribution to other members. Ceiling + mentioner-only-confirm + viewer-role-parity tests. - P3 — Mobile nav promotion. Promote Messages to a top-level slot on iOS and
Android; verify @lumi round-trip on the native shells; update mobile nav diagrams.
- iOS slice (built 2026-07-04). Messages is reachable via a fast-access top-bar
icon (
bubble.left.and.bubble.right) placed immediately to the right of the notification bell in the sharedMainTabViewtrailing toolbar — chosen over a fifth bottom tab to keep the tab bar calm and honour the “ONE entry to the existing screen” minimalism (Zulip-focused, not a feature farm). Tapping it pushesSharedLaneRoute.messages, which hosts the existing shared KMPMessagesScreenthrough the already-wiredSharedScreen.messages→MainViewControllerKt.makeMessagesViewControllerbridge (no new Kotlin entry point needed — the bridge, KMP factory, route, and render already existed; only the toolbar button was missing).xcodebuildBUILD SUCCEEDED on iPhone 17 Pro (iOS 26.4) sim; installs + launches. Visual confirmation of the icon in-place is blocked behind auth (needs a running backend), so the registry row stays🔲(compile-only ≠ shipped). Deferred:@lumicomposer autocomplete inside the shared Compose composer (separate shared-UI task;@lumialready round-trips when the literal text is typed since the backend triggers on the message text), and the Android nav-placement slice. - Android slice (built 2026-07-05). Mirrors the iOS slice: Messages is reachable via a
fast-access top-bar icon placed immediately to the right of the notification bell in
ShellTopRail(android/.../ui/LumiApp.kt). The icon isLumiIcons.Messages(Icons.AutoMirrored.Rounded.Message, a chat bubble — RTL-safe, distinct from the AutoAwesome assistant tab and the Mail icon) added toLumiIcons.kt; content descriptionR.string.top_rail_messages(“Messages” / “Tin nhắn”, added tovalues/+values-vi/). Tapping it callsnavigator.navigate(MessagesNavKey), which routes to the existing shared KMPMessagesScreenvia the already-wiredentry<MessagesNavKey>(no new entry point needed — the nav key, route, and render already existed; only the top-bar button was missing). Same “ONE entry to the existing screen” minimalism as iOS (no fifth bottom-nav slot, no composer redesign)../gradlew :app:compileDevDebugKotlin→ BUILD SUCCESSFUL. Visual confirmation of the icon in-place needs a running emulator + backend auth, so the registry row stays🔲(compile-only ≠ visually shipped). Deferred (unchanged):@lumicomposer autocomplete inside the shared Compose composer (@lumialready round-trips via literal text since the backend triggers on message text).
- iOS slice (built 2026-07-04). Messages is reachable via a fast-access top-bar
icon (
12. Open Questions / Decisions
- Auto-join vs first-mention. Does the system Lumi member auto-join every channel
as a silent member (so it’s always in autocomplete and can post) or is it added lazily on
the first
@lumiin that channel? Auto-join is simpler for autocomplete/posting but adds a member row per channel and a “Lumi is in this channel” perception that brushes against the trust line’s spirit; lazy-join is cleaner but needs autocomplete to surface a not-yet-joined Lumi. Leaning: always-available in autocomplete, join-on-first-mention for posting. Needs a decision (ADR). - @lumi inside a human 1:1 DM. Can you
@lumiinside a 1:1 DM between two humans, or is that redundant with/intel/chat(which is your private 1:1 with Lumi)? Options: (a) disallow in 1:1 human DMs and nudge to/intel/chat; (b) allow it (a shared 1:1 where both humans see Lumi’s answer) — but then “whose memory/permissions” gets subtle. Leaning: (a) for v1 — group DMs and channels only; 1:1-with-Lumi already exists. - Per-channel rate limit. Should there be a per-channel / per-member
@lumirate limit to prevent spam (e.g. N mentions/minute), and what’s the honest UX when throttled? F29 has no message rate limit today; @lumi is more expensive (a model call each). Leaning: a light per-member cooldown, surfaced as a calm “give me a moment” rather than a hard error. - Confirm-card visibility. Should a proposed action’s confirm card be visible/actionable
to managers too (a manager could confirm on the mentioner’s behalf), or only the
mentioner? B4 says only the mentioner acts (with their permissions). Leaning: confirm
is mentioner-only (authority parity); managers see the pending proposal but do not
confirm it — a manager who wants to act should
@lumithemselves. - How much same-topic context is “minimal”? B1 requires Lumi read only the invoking message + the minimal same-topic context needed to answer. What is the bound — last N messages in the topic, a token budget, or the current topic only since some watermark? Too little and answers are context-blind; too much and it strains the trust line. Leaning: a small fixed same-topic window (e.g. last ~15 messages / a token cap), same-topic only, never cross-topic or cross-channel — pin the exact number with an eval before P1 ships.
- Invoking-message → agent-trace linkage shape. Metadata in
agent_trace_jsonvs a widenedAssistantAction.source_message(§4). Constrained by F29 §4.1 rule 2 (no new hot FK intomessaging_message). Requires an ADR before P1.
13. Bug / Change Log
2026-07-16 — Contract Graph migration and status reconciliation
Type: Contract Graph + Documentation
Summary:
- Added canonical
contracts/features/F92.cueownership for the trust-line trigger, grounded reply, confirm-before-commit actions, web rendering, and shared mobile messaging state. - Added runner-native proof markers and a source guard for the existing backend/web/native seams.
- Corrected the stale
draft/unbuilt narrative topartial; the current top-rail implementation does not satisfy the spec’s mobile top-level-slot wording, so that contradiction remains explicit. - Preserved missing runner, browser, named-device, live-provider, production, and IA evidence as blocking gaps rather than treating collected proofs as green.
2026-07-05 — Single-loop unification: one grounded pass, redundant Gemini call removed
Type: Refactor / perf (behavior-preserving)
Summary:
- Problem. Stage 1 made an
@lumimention run two grounded model paths: P1’sanswer_chat_message(1 Gemini call) for the reply, thenrun_agent_loop_taskon the bridge thread (2–3 more calls) to harvest actions — ~3–4 model calls / two grounding paths where one would do (wasteful latency + cost). - Fix. Unified to a single agent-loop pass.
_propose_thread_actions→ renamed_run_lumi_thread_turn, which runsrun_agent_loop_taskONCE and returns both the loop’s groundedFinalAnswer(read from the bridge assistantChatMessage.contentbefore the no-action bridge cleanup — reordered so the answer survives the delete) and the harvested action metadata.answer_lumi_mention_taskuses that loop answer as the reply and drops the separateanswer_chat_messagecall; the single-shot path survives only as an ES1 fallback for the edge where the loop hard-fails with an empty body. The bridge is now seeded with the same bounded same-topicinput_textthe single-shot path used, so the loop reply is at least as context-aware._build_action_ackwas removed — the loop’s ownFinalAnswernarrates the proposal (behind the F91 §11 honesty guard), so no double-narration. - Invariants preserved. One grounding path, one gate: the loop’s
summary_introduces_new_factfallback replaces an ungrounded answer with the safe grounded fallback (B3). Acting member = the mentioner (role-filtered toolbelt, B4); tenant-scoped (B5); one reply per mention, no@lumi→ no loop (B1/B6); money/external/delete stay ceiling-0, mentioner-only confirm (T1–T6). A loop failure degrades gracefully (ES1).
Files:
backend/apps/messaging/tasks.py—answer_lumi_mention_task(single-pass + ES1 fallback),_run_lumi_thread_turn(was_propose_thread_actions; now returns(answer_text, action_meta), reads the loop answer before cleanup); removed_build_action_ack+ the local_member_lang.
Tests:
tests/test_f92_lumi_messaging.py— migrated the 3 tests that spied onanswer_chat_message(tenant isolation, mentioner-role parity, grounding) to assert the loop is the reply source; newTestLumiSingleGroundingPass(3 cases): a mention makes ONE grounding pass and does not invokeanswer_chat_message(call-count 1→0), the loop provider is the only grounding surface, and a hard loop failure degrades to the single-shot fallback. Grounding preserved: an ungrounded scripted loopFinalAnswer→ the safe grounded fallback is what posts.tests/test_f92_lumi_actions.py(T1–T6) stay green unchanged (money never auto-executes; mentioner-only confirm).- Verification: RED proven against pre-refactor
tasks.py(test_mention_makes_one_grounding_pass_not_twofailed because the old path always calledanswer_chat_message), GREEN after. F92 files 29 passed; full backend suite 6424 passed, coverage 97.07% (≥97);makemigrations --check— no model change.
2026-07-05 — P2 stage 2 (web): render the confirm-before-commit ActionCard in the thread
Type: Feature
Summary:
- The confirm-before-commit
AssistantAction(s) stage 1 wrote into the Lumi reply’smetadata_jsonare now exposed and rendered in the web chat thread, closing the P1/stage-1 gap where the reply said “…confirm below” but nothing rendered below. - Backend:
MessageSerializer(apps/messaging/serializers.py) gains three read-only, backward-compatible fields —lumi_actions([{action_id, action_type, title}]frommetadata_json.actions),lumi_action_actor_id(the mentioner — the only member the server lets confirm),lumi_action_actor_name(for the pending line) — allnullon a normal message.tasks.pynow also stampsmentioner_id+mentioner_nameinto the reply metadata (no extra read-time query). No new migration (reuses the stage-1metadata_jsoncolumn). - Frontend:
MessageItem.tsxrenders the sharedActionCard(components/chat/ActionCard.tsx, reused as-is — confirm/reject wired to the existingassistantActionsApi) per action below the body. Pending-confirm attribution (B4): the mentioner (lumi_action_actor_id === myMemberId) sees the actionable card; everyone else sees a read-only “Lumi proposed <title> — pending <name>‘s confirmation” line with no buttons (mirrors the server-enforced mentioner-only confirm). New i18nmessages.lumi_action_pending+lumi_action_pending_fallback_name(en/vi translated, English fallback in the other locales per the translation-fleet convention).
Files:
backend/apps/messaging/serializers.py(MessageSerializer— 3SerializerMethodFields +_lumi_action_metahelper),backend/apps/messaging/tasks.py(metadata now carriesmentioner_id/mentioner_name).frontend/src/types/index.ts(MessageLumiActiontype + 3Messagefields),frontend/src/components/messaging/MessageItem.tsx(render + attribution),frontend/src/i18n/locales/*/messages.json(new keys).
Tests:
- Backend
tests/test_f92_lumi_actions.py+2 (TestLumiActionSerializerSurface): fields exposed on a Lumi action-reply,nullon a normal message. Frontendsrc/test/connect/MessageItemLumiActions.test.tsx(3): mentioner-actionable, non-mentioner read-only pending, plain message renders clean. - Verification: backend full suite green at 97.12% coverage; frontend 11669 unit tests green, 0 tsc errors.
Why:
- Deliver P2 stage 2 (web) of §11’s plan — the mentioner can now confirm/reject an @lumi-proposed action in-thread, and other members see honest pending attribution — without touching the server-enforced trust logic. Registry native-platform cells unchanged (mobile card is a follow-up).
2026-07-05 — P2 stage 1: confirm-before-commit actions from an @lumi mention (backend)
Type: Feature
Summary:
- An
@lumimention with an ACTION intent now proposes a confirm-before-commitAssistantAction, confirmed by the mentioner with their role-filtered permissions; money/external/delete stay ceiling 0 forever. Routed through the same F91 agent-loop machinery/intel/chatuses — role-filtered toolbelt + ceiling registry + grounding gate + confirm/reject apply identically (no parallel action path). apps/messaging/tasks.py::_propose_thread_actionsruns the mention throughapps.chat.tasks.run_agent_loop_taskon an ephemeral bridgeChatThread(owned by the mentioner, markedLUMI_MESSAGING_BRIDGE_TITLE, excluded from/intel/chatinapps.chat.views.list_threads), seeded with only the mention text (grounding stays in the mentioner’s own words, B1/B3). Actions are harvested; the reply text stays P1’sanswer_chat_message. No action → bridge rows deleted (zero pollution); action → thread kept (AssistantAction.threadFK). Best-effort — a loop failure never fails the reply (ES1).- Reply → action linkage rides a new nullable
Message.metadata_jsoncolumn (migrationmessaging/0005_message_metadata_json.py):{"origin":"messaging","source_message_id":…, "actions":[{action_id, action_type, title}]}— spec §4 option (b) metadata, not a new FK intomessaging_message(F29 §4.1 rule 2). A proposal-phrased acknowledgment (never a completion claim, F91 §11) is prepended to the reply (en/vi).
Reason:
- Deliver P2 stage 1 of §11’s plan (thread actions confirmed by the mentioner) trust-invariant first, TDD — the multi-user expression of F91’s confirm-before-commit doctrine in team threads.
Impact:
- Backend seam + one nullable JSON column + migration. No new HTTP endpoint (rides the existing
F91 confirm/reject endpoints). Registry row stays
🔲(no UI / mobile surface). P2 stage 2 (ProposalCard rendering + pending-confirm attribution) and the single-loop unification remain.
Tests:
backend/tests/test_f92_lumi_actions.py— 9 cases: T1 propose-not-execute (+ metadata linkage), T2 money ceiling-0 never executes, T3 viewer role-filter, T4 mentioner-only confirm/reject via the existing endpoint (non-mentioner → 404), T5 trust line + org-A scoping. P1’s 15 cases stay green. Full suite green, coverage 97.12%.
2026-07-05 — Mobile “Ask Lumi” composer affordance (shared KMP)
Type: Feature
Summary:
- Added a discoverable one-tap “Ask Lumi” affordance to the shared Compose messaging
composer so mobile users can invoke the assistant without typing the literal
@lumihandle. A small leadingIconButton(32.dp circularSurfacetintedLumiBlue.copy(alpha = 0.12f), a✨sparkle glyph) sits beforeLumiChatInputFieldin the sendRow(android/shared/src/commonMain/kotlin/com/locuno/lumi/shared/ui/messages/MessageThreadScreen.kt, ~L178–221) — reads as “tap to bring Lumi in, then type your question”. - Icon choice: a
Text("✨")glyph inside aSurface/Box, mirroring the existing send button’sText("↑")pattern in the same file. Chosen over Material vector icons because the composer already renders its controls as text glyphs and this compiles cleanly on both iOS and Android fromcommonMain(no Material-icons-extended dependency, no expect/actual). - On tap it calls the new
MessagesScreenModel.askLumi(): idempotently prefixes the draft with@lumi—"@lumi "when blank,"@lumi " + draftotherwise, no-op if the draft already contains@lumi(case-insensitive). The backend F92 P1 trigger then routes the grounded reply. Handle stored asprivate companion object { const val LUMI_HANDLE = "@lumi" }. - Disabled while
state.isSending. Accessibility:Modifier.semantics { contentDescription = … }bound to the localized label. - i18n: added
messages_ask_lumi= “Ask Lumi” (en) / “Hỏi Lumi” (vi) alongsidemessages_input_placeholderincomposeResources/values/strings.xmlandvalues-vi/strings.xml— the only two locales that define the sibling key, so no locale is missing it. - Tests: three pure
askLumi()unit cases added toMessagesScreenModelTest(blank →"@lumi ", existing draft prepend, idempotent when@LUMIalready present).
Scope discipline:
- No full
@-mention autocomplete picker was built — one focused affordance only, no settings, no changes to the send flow or message rendering (Assistant-First “no feature farm”).
Impact:
- Shared KMP, additive.
:shared:compileKotlinIosSimulatorArm64→ BUILD SUCCESSFUL;:app:compileDevDebugKotlin→ BUILD SUCCESSFUL;:shared:jvmTest --tests MessagesScreenModelTest(12 cases incl. the 3 new) → BUILD SUCCESSFUL. MVP_SCOPE F92 mobile rows stay🔲(compile + unit verified; not visually confirmed on device behind auth).
2026-07-05 — P3 Android slice: Messages reachable via top-bar icon
Type: Feature
Summary:
- Mirrored the iOS P3 slice on Android: added a fast-access Messages top-bar icon to
ShellTopRail, placed immediately to the right of the notification bell (android/app/src/main/kotlin/com/locuno/lumi/android/ui/LumiApp.kt). NewonMessagesClickparam onShellTopRailrenders anIconButton(Modifier.padding(start = 8.dp), no badge) usingLumiIcons.Messages; wired at the call site tonavigator.navigate(MessagesNavKey). - Added
LumiIcons.Messages = Icons.AutoMirrored.Rounded.Message(RTL-safe chat bubble, distinct from the AutoAwesome assistant tab and the Mail icon) toandroid/core/designsystem/.../icon/LumiIcons.kt. - Added content-description string
top_rail_messages(“Messages” / “Tin nhắn”) to both strings.xml files that carrytop_rail_notifications(values/,values-vi/) — the only two locales that define the sibling key. - No new nav wiring required:
MessagesNavKey, itsentry<MessagesNavKey> { MessagesScreen(...) }route, and the shared KMPMessagesScreenall already existed — only the top-bar button was missing.
Reason:
- Deliver the Android slice of §11’s P3 (mobile nav promotion) — one intentional entry point to the existing messaging screen, per the Assistant-First “no feature farm” minimalism (no fifth bottom-nav slot, no composer redesign).
Impact:
- Android-only, additive.
./gradlew :app:compileDevDebugKotlin→ BUILD SUCCESSFUL (only pre-existing deprecation warnings, none from this change). No unit test enumerates top-rail actions, so none needed updating. Icon-in-place not visually confirmed (needs emulator + backend auth), so the MVP_SCOPE F92 Android row stays🔲(compile-only ≠ visually shipped). Deferred (unchanged):@lumiautocomplete in the shared Compose composer (@lumialready round-trips via literal message text).
2026-07-04 — P3 iOS slice: Messages reachable via top-bar icon
Type: Feature
Summary:
- Brought internal chat (F29) onto iOS as a fast-access top-bar icon reusing the existing
shared KMP
MessagesScreen. Added abubble.left.and.bubble.rightbutton to the sharedMainTabViewtrailing toolbar (ios/Lumi/UI/MainTabView.swift, in the sameToolbarItemGroup(.topBarTrailing)as search + the notification bell, placed to the right of the bell). Tapping it pushesSharedLaneRoute.messages. - No new bridge/KMP/route wiring was required:
SharedScreen.messages→MainViewControllerKt.makeMessagesViewController(SharedScreenBridge.swift), theSharedLaneRoute.messagesenum case, itsdestinationViewrender, and the KMPmakeMessagesViewControllerfactory (iosMain/.../MainViewController.kt) all already existed and the releaseshared.xcframeworkalready exported the symbol — so the XCFramework did not need regenerating. Only the toolbar button was missing.
Reason:
- Deliver the iOS slice of §11’s P3 (mobile nav promotion) — one intentional entry point to the existing messaging screen, per the Assistant-First “no feature farm” minimalism.
Impact:
- iOS-only, additive.
xcodebuildBUILD SUCCEEDED (iPhone 17 Pro, iOS 26.4 sim); app installs + launches. Icon-in-place not visually confirmed (blocked behind auth / backend), so the MVP_SCOPE F92 row stays🔲(compile-only ≠ shipped). Deferred:@lumiautocomplete in the shared Compose composer and the Android nav-placement slice.
2026-07-04 — P1 backend landed (presence + grounded reply)
Type: Feature
Summary:
- Built F92 P1 backend: the system Lumi member,
@lumireserved-handle resolution, the async trigger seam frompost_message, and the grounded in-thread reply. OrganizationMember.is_system(boolean,db_default=False; migrationorganizations/0020_f92_orgmember_is_system.py) marks the per-org reserved Lumi identity. New helperapps/messaging/lumi_member.py(ensure_lumi_member/get_lumi_member) — idempotent, lazy, backed by an inactive systemUseratlumi+<org_id>@system.lumi.parse_mentionsresolves@lumiin a dedicated reserved branch (parallel to@channel); system members are excluded from ordinary@handleresolution so a human named “Lumi” cannot shadow the handle.ensure_default_channeland_send_mention_pushesexcludeis_systemmembers — the aggregate-exclusion guard against the 2026-06-21 self-message/unread bug class.post_message→_maybe_trigger_lumienqueuesanswer_lumi_mention_taskonly when the Lumi member id is inmentioned_ids(B1 trust line), only on channels + group DMs, never on Lumi’s own message (B6). The task reuses the vetted grounding pathapps.ai.services.answer_chat_message(same F86 faithfulness gate as/intel/chat, B3), acts as the sender (B4), stays org-scoped (B5), gathers a bounded same-topic context window (LUMI_CONTEXT_WINDOW = 10), and posts exactly one Lumi-authored reply in the same channel+topic over the F29 SSE stream.
Reason:
- Deliver the P1 slice of §11’s build plan (presence + grounded reply) trust-line-first, TDD.
Impact:
- Backend seam + one new model boolean + migration. No new HTTP endpoint. Registry row stays
🔲(no UI / mobile surface shipped). P2 (thread actions) and P3 (mobile nav) unbuilt.
Tests:
backend/tests/test_f92_lumi_messaging.py— 15 cases (trust line B1, resolution, one reply same channel+topic, group DM, 1:1-DM out-of-scope, grounding B3, tenant isolation B5, restraint B6, mentioner-role parity B4-slice, aggregate exclusion). Full suite green, coverage 97.14%.
2026-07-04 — Initial spec
Type: Feature
Summary:
- Created F92 — @lumi in Messaging: Lumi as a mentionable, first-class team member inside
F29 channels/topics/group-DMs, powered by the F91 agent loop (no new brain, no bot
platform). Defines the trust-line-preserving
@lumiexplicit-invocation trigger, the per-org system Lumi member, in-thread grounded reply, confirm-before-commit actions carried out with the mentioner’s role-filtered permissions (money/external/delete ceiling 0 forever), tenant isolation, restraint (one reply per mention), and the mobile nav promotion of Messages. - Added the F92 registry row to
docs/MVP_SCOPE.mdat status🔲(spec written, not built); all platform columns🔲.
Reason:
- F91 made Lumi a private 1:1 assistant; the company’s real work happens in F29 team conversations. F92 is the multi-user expression of the assistant-first doctrine — Lumi where the team already is.
Impact:
- Spec + registry only. No code, no data-model, no API change yet. Build is P1→P2→P3 (§11).
Tests:
- None (spec-only). §7 enumerates the planned, currently-unbuilt test matrix.
Notes:
- Two structural decisions require an ADR before P1: the invoking-message → agent-trace
linkage shape (F29 §4.1 rule 2 forbids a new inbound FK into
messaging_message) and the system Lumi member’s auto-join policy (§12 Q1, Q6). The trust-line trigger (loop fires only when the Lumi member is inmentioned_ids) is the single most important invariant to test (§7 row 3).
2026-07-05 — P2 stage 2 (mobile) — @lumi action card in the KMP chat thread
Type: Feature (mobile parity)
Summary:
- Mirrors the web stage-2 on iOS + Android (shared KMP).
MessageResponsegainslumi_actions/lumi_action_actor_id/lumi_action_actor_name(matchingMessageSerializer).MessageThreadScreenrenders the sharedActionCardper action for the mentioner (confirm/reject viaconfirmAssistantAction/rejectAssistantAction), else a read-only “Lumi proposed— pending <name>‘s confirm” line. Money/external/delete stay confirm-only (ActionCard ceiling); trust is server-enforced (non-mentioner confirm → 404), the UI only mirrors it. MessagesScreenModelresolves + caches the current member id (am-I-mentioner = actorId == currentMemberId) and holds optimistic pending/confirmed/rejected state.
Impact:
- Shared KMP mobile only (
LumiDtos,MessagesRepository,MessagesScreenModel,MessageThreadScreen) + 2 i18n keys. No backend change.
Tests:
- +9: MessagesScreenModel (member-id resolve, am-I-mentioner, confirm/reject state,
error path) + DtoContract (lumi_* decode / defaults).
:shared:jvmTest903 passed, 0 failures, 3 known-skiko skipped. Both platforms compile.
Result: @lumi is now end-to-end on every surface — answer + confirm-before-commit action
— web, iOS, and Android. Remaining: stage-1 single-loop unification; live action-status
sync on the card (metadata freezes at proposed).