Spec: F29 — Internal Chat (Messages)
Status: shipped foundation · Messages completeness expansion in progress (2026-08-03)
Reference study: reference/zulip/ — see §11 for what we adopt and what we deliberately reject.
1. Problem & CEO Job
Own the signal, don’t fight the chat. The org’s operational talk already lives in familiar, mobile-native Zalo groups. Forcing owners to leave on day one asks for the behavior switch SMEs resist most. Internal chat is therefore opt-in signal capture beside Zalo, not a Slack/Teams replacement or migration mandate: a team brings in work that must not be forgotten so it becomes searchable, extractable, and linkable to tasks, CRM, and briefs. The bar is the 90% of in-product use that earns its keep — channels, DMs, mentions, search, and mobile — not feature parity. Lumi wins by accepting the mess (F70 imports pasted Zalo threads) and becoming the safer home for accountable work, gradually rather than by ultimatum.
The design center, stolen from Zulip: every channel message belongs to a named topic. This is the one architectural idea that makes chat work for a CEO — instead of scrolling 400 messages, they skim 12 topic lines (“Đơn Vincom trễ”, “Tuyển kế toán”) and open two. Topics also give Lumi natural extraction units and linkable evidence — Slack’s unstructured stream can’t do that.
The trust line (non-negotiable): Lumi’s AI does not ambiently read chat. Pulses are the consented signal channel (F07). Chat content reaches the AI pipeline only when a human explicitly flags a message/topic as signal (§2). A CEO tool the team believes is surveilling them produces dishonest pulses and dead chat — the explicit-flag rule protects both.
2. User Stories & Acceptance Criteria
- As a member, I want channels with topics, so that conversations stay findable.
- Channels: public (anyone in org joins/reads) or private (invite-only, history visible to members — Zulip’s “shared history” variant only). Created by manager+; a default
#chungchannel exists per org (seeded).is_privatefield enforces access viaget_queryset;IsOrgManagerpermission gates create/update/destroy/archive/unarchive and archived-list access;ensure_default_channel()called increate_organization()(orgs/views.py line 99). The default channel cannot be archived; archive is reversible, hides the channel from active lists, and managers can restore it from the archived list. - Every channel message carries a topic (≤60 chars).
topicfield on Message model (max_length=60, blank=True). The composer suggests recent topics in the channel; replying inside a topic inherits it. - Channel view is topic-grouped by default (latest activity first), with a flat chronological toggle. MessagesPage.tsx implements topic grouping and flat toggle.
- The native and web channel directories keep the channel name as the primary line and expose the newest visible live message as one bounded, markdown-free secondary preview (or a localized attachment label). Empty channels keep their creation-time timestamp; tombstoned messages never appear in the preview.
- Channels: public (anyone in org joins/reads) or private (invite-only, history visible to members — Zulip’s “shared history” variant only). Created by manager+; a default
- As a member, I want DMs (1:1 and small group), so that quick questions don’t need a channel.
- DM conversations are private to participants; group DMs up to ~8 people.
GROUP_DM_MAX_PARTICIPANTS = 8defined at messaging/services.py line 16; enforced at lines 63-64. - Native and web New message surfaces support selecting one to seven active teammates before creating a conversation. The creator is added by the server, the UI shows checked selection state and a visible eight-participant cap, and one selected teammate keeps the existing 1:1 behavior while multiple selections create a bounded group DM. Group-DM directory rows render up to three participant names plus a localized remainder count instead of hiding the group behind one teammate.
- The native New message picker matches active member names without case or Vietnamese tone-mark sensitivity. Plain text searches the email local part only, so a query such as
cocannot match unrelated members through a hidden.comdomain; typing@opts into full-address search. Each named result shows its email as quiet secondary identity, while an email-only result stays single-line. Tenant, system-member, and active-status filters remain unchanged. - The native directory uses a group glyph for bounded group DMs and a person glyph for 1:1 DMs, with a localized accessibility label that names the conversation type before the participant names. The distinction is visual and semantic only; it does not add another navigation step.
- The DM directory is ordered by the newest visible message, and each row shows a single-line, markdown-free preview (or a localized attachment label). A conversation with no live messages falls back to its creation time and does not expose tombstoned content.
- DM conversations are private to participants; group DMs up to ~8 people.
- As a member, I want @mentions and unread state that doesn’t lie.
-
@namenotifies the mentioned member (in-app badge);@channelis manager+ only —PermissionErrorraised in messaging/services.py line 92 if non-manager uses@channel. - Unread counts per channel/DM driven by per-conversation message-id read watermarks; opening a native thread advances the exact newest visible message only after the selected channel or conversation page succeeds. Watermarks are monotonic, never cross container boundaries, and a failed update keeps the thread readable with an explicit localized Retry action.
- Ordinary message pushes remain in this watermark-owned stream: they retain a hidden route-bearing compatibility event plus delivery telemetry, but never enter the NotificationEvent center projection; Android and iOS shell badges sum channel and DM unread projections independently, while direct mentions continue to use the notification center.
-
- As a channel member, I want to see who is in the channel, so that I know who can read
and respond.
- Every visible channel exposes its active member directory without leaking another tenant or a private channel the caller cannot see.
- An authorized manager may add or remove active organization members on an ordinary channel; default-channel membership and linked-channel inherited membership remain protected.
- The manager add picker searches active workspace members by name or email, orders matches deterministically, and explains when the search has no results; the search is local to the already tenant-scoped directory and does not change roster authorization.
- As a channel member, I want notification control per channel, so that high-signal
channels interrupt me while low-signal channels stay quiet.
- An active member can choose all messages, mentions only, or muted for each visible channel; the preference is tenant-scoped and server-owned, while linked-channel roster governance stays with the parent container.
- The selected channel header reflects the saved mode immediately after load and after a successful change, while the dialog remains the explicit edit surface. If a save fails, the previous confirmed mode stays visible and Retry replays the exact requested mode once.
- As a member, I want to attach business files and images, so that the conversation can
carry the same daily evidence people currently exchange in Zalo.
- A message may contain a bounded set of completed, tenant-owned private uploads; attachment authorization follows message visibility and never exposes a durable public URL.
- Mobile preserves the draft and selected files across retryable failures, shows upload progress, supports removal before send, and opens an authorized preview/download after send.
- Native attachment rows expose a localized opening state, suppress duplicate taps while requesting the short-lived URL, and discard stale callbacks when the member changes threads.
- Native image attachments prefetch a visibility-authorized short-lived URL only for the visible channel/DM thread, render an inline thumbnail with the filename/size fallback, and discard preview state on a thread or topic change.
- Web reaches parity for the send path: the composer uses the private
message_attachmentpurpose, accepts text-plus-file or attachment-only messages, preserves the draft after a send failure, renders compact metadata rows, and requests a fresh authorized download URL on click.
- As a CEO, I want to flag a message or topic as signal, so that chat feeds Lumi only when humans say so.
-
flag_message_as_signalcalled at messaging/views.py line 330 (MessageFlagSignalView); createsEvidenceSource(source_type="chat_note") with message text + permalink metadata. - Shared Android/iOS channel and direct-message bubbles expose this explicit action through one
compact 48 dp overflow control. The client sends
extract=false, suppresses pending or already captured duplicates, preserves concurrent message reconciliation, and renders localized pending, captured, and retryable-error states from server truth.
-
- As a member, I want search, so that “đã chốt giá bao nhiêu?” is answerable.
-
search_messagesservice at messaging/views.py line 355; searches across channels I’m allowed to see, filterable by channel and sender. - Shared Android/iOS Messages exposes recent or all-history search. Selecting a result loads a bounded server context around that exact message, highlights it, and labels the historical context until the member deliberately jumps back to latest.
- Message search folds Vietnamese tone marks and case for topic/body matching, prefixes each
query word (so
ThafindsThảo), and a non-whitespace query of one character is valid; blank/whitespace-only input remains rejected.
-
- Messages are markdown (same renderer + sanitizer as F28); edit window 15 minutes with “(đã sửa)” marker; delete = soft-tombstone (“tin nhắn đã xóa”) — history honesty over vanity.
- Emoji reactions: standard set, one tap. (Reactions cut message volume — “ok”/”👍” noise becomes metadata, which matters for topic skimmability.)
- As a member, I want to reply with visible context, so that an answer remains
understandable without creating another nested thread.
- A live channel message can reply only to a live parent in the same channel and topic; a direct message can reply only to a live parent in the same conversation.
- Shared Android/iOS shows one accessible Reply action, a cancellable composer quote, and the persisted parent sender/body preview; tapping that preview loads and highlights the exact parent when available, while retryable send failure preserves both draft and target.
- Web channel and DM threads expose the same Reply action, keep the selected parent visible
in the composer, send the exact
reply_toid, render the persisted quote, and preserve the target when the send fails.
- As a team or project member, I want linked channels to follow the parent roster, so
that I never join twice and a removed member cannot retain discussion access.
- Given a linked channel, when its team/project roster changes, then every
ChannelMemberrow is synchronized in the same transaction. - Given a linked channel, when any caller uses generic join/leave or direct membership
mutation, then the request fails with
400 membership_inherited. - Given a user cannot view the parent team/project, when they address its channel or messages directly, then the server returns 404.
- Given a team/project create succeeds, then exactly one protected default linked channel exists; authorized users may add more channels through the parent detail. The transactional team/project create services materialize that channel and the parent detail owns additional linked-channel creation.
- Given a linked channel, when its team/project roster changes, then every
3. Scope Boundaries
- In: public/private channels, team/project-linked channels, inherited membership, channel member directory and ordinary-channel roster management, topics, DMs/group DMs, mentions, unread watermarks, reactions, markdown, edit/delete, reversible channel archive, private message attachments, search, flag-as-signal, default channel seeding.
- Out (hard — each is a Slack rabbit hole): voice/video calls (Zalo/Meet exist), threads-within-topics, custom emoji, integrations/webhooks/bot platform, presence dots + typing indicators, scheduled messages, drafts sync, notification routing matrices beyond the three bounded channel modes, guest/external accounts, channel-only members or roles on linked channels, multi-container channels, and WebSockets.
4. Data Model (as built — app messaging; F14 chat remains separate)
The F14 models (ChatThread/ChatMessage for AI chat) remain untouched in app chat; F29 lives in app messaging (§9 ADR). All models extend UUIDModel — ids are UUIDv7 (time-ordered). For the message store that property is load-bearing, not incidental: it is the keyset-pagination cursor, the unread watermark, and the bridge to time-range partitioning (§4.1).
Channel— org FK, name (unique per org), description, is_private, created_by,is_default,is_parent_default, is_archived, nullableteam, nullableproject. A database check allows at most one parent.is_defaultremains exclusive to the organization-wide#chung; linked channels use a separateis_parent_defaultflag. Both parent FKs useon_delete=CASCADE, so explicit parent deletion cannot leave an orphan private ACL. Linked channels are private; conditional uniqueness permits at most one parent-default per team/project, and each parent is capped at 50 linked channels.ChannelMember— channel FK, member FK, joined_at,is_active,last_read_message_idwatermark (UUIDv7 — compares in message order; no clock-skew games, partition-prunable unread math); unique (channel, member). Private-channel ACL = active row existence. Suspension sets inactive and preserves the watermark; reactivation restores access without replaying all history as unread.ChannelTopic— channel FK, name (≤60),last_message_id,last_message_at; unique (channel, name). Maintained in the same transaction as channel-message writes. The topic digest reads this table — never a GROUP BY over messages — and topic-level “has unread” falls out free (last_message_id > my watermark). Also the future home of topic rename/archive.Conversation— org FK, kind:dm|group_dm; participants viaConversationMember(member FK,last_read_message_id); unique 1:1 pair enforced via normalized participant hash. Directory responses include the newest visible message timestamp, a bounded markdown-free preview, and an attachment-only marker; these are projections, not persisted conversation state.Message— org FK, exactly one of channel FK / conversation FK (DB check constraint); sender (OrganizationMember),topic(CharField ≤60, blank for DMs), body_md,search_tsv(generated tsvector column, GIN),mentions(JSON array, not M2M), nullablereply_toUUID soft reference validated in the write service, edited_at, deleted_at, andis_deleted.Reaction— message FK, denormalizedmessage_created_at(copied on create), member FK, emoji; unique (message, member, emoji). The copied timestamp is what lets Reaction be co-partitioned with Message later instead of being the FK that blocks the conversion.- Read state: id-watermarks per channel/conversation — not per topic, not per message; revisit only if real usage complains. Not adopting Zulip’s per-user-per-message
UserMessagerows (right for 10k-seat realms, oversized for ≤200) — but their id-based unread accounting is: unread =id > watermark, never a timestamp comparison.
4.1 Partition readiness (decided now, partitioned later)
messaging_message (with messaging_reaction trailing it) will dwarf every other table in the product. We do not partition in v1 — at SME scale a well-indexed plain table is faster and simpler. What we do is hold six rules from the first migration, so that the later conversion to monthly range partitions on created_at is a mechanical DB migration with zero application-code changes:
- No unique constraint on Message beyond the PK. Postgres requires every unique constraint on a partitioned table to include the partition key; the conversion PK is
(id, created_at). UUIDv7 ids stay globally unique, so Django keeps treatingidas the pk — the composite is a DB-level concern only. - Nothing long-lived holds an FK into Message except Reaction, which carries
message_created_atfor exactly this reason. Mentions are an array on the row, not a through table. Flag-as-signal copies text into EvidenceSource rather than referencing — evidence survives retention drops by design. - All message reads are keyset, never OFFSET. Cursor = message UUIDv7 id; composite indexes
(channel, topic, id DESC)and(conversation, id DESC). A UUIDv7 cursor carries its timestamp, so post-partition queries can add thecreated_atbound that makes partition pruning actually fire. - Unread math is bounded: counts computed as
id > watermarkwith a server-side display cap (99+) — never an unboundedCOUNT(*)over history. - Search defaults to a recent window (90 days; explicit
all=trueopt-out) so per-partition GIN indexes prune later;search_tsvis a generated column from day one, not an afterthought backfill. ChannelTopicis the only topic aggregate. It exists precisely to kill the one query (latest-activity-per-topic) that would otherwise force a full-table scan no matter how the table is partitioned.
The conversion runbook (manual SQL or pg_partman; Django sees it via SeparateDatabaseAndState) is recorded in the messaging ADR. The test suite enforces the invariants that are checkable (§7 row 5b).
State machine: messages have none (tombstone is a field, not a status). ER → backend/DIAGRAMS.md. GLOSSARY: Channel, Topic, Conversation — and disambiguation from F14 ChatThread (mandatory: “chat” now means two things; the glossary entry prevents agent confusion).
5. API Contract (as built)
| Method | Path | Min role | Notes |
|---|---|---|---|
| CRUD | /api/messages/channels/ | manager (create) / member | Join/leave actions; private ACL server-side |
| GET | /api/messages/channels/archived/ | manager | Lists tenant-visible archived channels for restoration |
| POST | /api/messages/channels/<id>/{archive,unarchive}/ | manager | Reversible lifecycle actions; default channel cannot be archived; active reads omit archived channels |
| GET/POST | /api/messages/channels/<id>/members/ | member* / manager | Active roster; POST adds an active org member to an ordinary channel |
| DELETE | /api/messages/channels/<id>/members/<member-id>/ | manager | Removes an ordinary-channel member; linked/default membership is protected |
| GET | /api/messages/channels/<id>/topics/ | member* | Topic digest served from ChannelTopic: name, last activity, unread flag |
| GET/POST | /api/messages/channels/<id>/messages/?topic=&before=<message-id>&around=<message-id> | member* | Keyset-paginated newest page, or a bounded exact context when around belongs to that visible channel/topic; foreign anchors return 404; post requires topic |
| GET | /api/messages/channels/ | member | Channel rows include a bounded latest-live-message preview, attachment-only marker, and activity timestamp; deleted messages are excluded and empty channels fall back to created_at |
| GET/POST | /api/messages/conversations/ + …/<id>/messages/?before=&around= | participants | DMs; list returns newest-visible-message activity/preview projection, same cursor pagination and exact-container anchor rule |
| GET | /api/messages/<id>/attachments/<upload-id>/download/ | visible message member / participant | Fresh short-lived private download URL; visibility is revalidated for every request |
| POST | /api/messages/<id>/react/ …/edit/ …/delete/ …/flag-signal/ | member / sender / sender / member | Edit window enforced server-side |
| POST | /api/messages/read/ | member | Advance watermark to a message id (channel or conversation); watermark only moves forward |
| GET | /api/messages/search/?q=&channel=&sender=&all= | member | Visibility-scoped; defaults to last 90 days, all=true for full history (§4.1) |
| GET | /api/messages/events/ | member | SSE stream: message/reaction/read events for my visible conversations; heartbeat ~25s |
* member of that channel for private channels. Tenant-scoped everywhere; DM/private-channel visibility is the second tenant-isolation problem — test it with the same severity as cross-org.
Team/project-linked channels are created and listed through F03/F04 nested routes, not by client-supplied parent fields on the generic channel endpoint. Generic join/leave rejects linked channels. Every roster mutation synchronizes active and archived linked channels. Generic archive/delete rejects a linked default; additional linked-channel lifecycle operations re-authorize against the parent container and current tenant.
The global Messages directory lists each visible linked channel once with its parent label and opens the canonical thread. It exposes no Join/Leave or direct edit/archive/delete action for a linked row; Open team or Open project routes to the parent for governance. This avoids a second ACL surface while preserving global conversation discovery.
Transport: SSE (Server-Sent Events) for server→client push, plain HTTP POST for sends. One GET /api/messages/events/ stream per client delivers new-message/reaction/read events for the member’s visible conversations; sending stays a normal request. SSE over WebSocket is deliberate: one-directional push is all chat needs, it’s plain HTTP (no protocol upgrade, ALB-friendly, no Django Channels dependency), and it degrades gracefully — on stream failure the client falls back to 30s polling and retries the stream. Infra notes for the ADR: ALB idle-timeout vs heartbeat events (~25s keepalive), worker model for long-lived connections (ASGI or django-eventstream), per-user event filtering server-side (never broadcast then filter client-side — that’s a tenancy leak).
6. UI / UX
- New route
/hub/messages— three-pane on desktop (channels+DMs / topic list / messages), two-level drill on mobile. Members live here all day, so this page earns the most layout care after the Brief. /hub/messagesis a full-bleed workspace surface, not a centered document page: it bypasses the app shell’smax-widthwrapper, keeps the channel/topic rails pinned to the left edge, and lets the conversation pane consume the remaining canvas like a real messaging client.- Sparse threads stay top-aligned instead of being bottom-pinned into a large dead zone; the composer remains at the bottom of the split view, but the reading flow starts from the top on both desktop and mobile.
- Keyboard ownership: the shared mobile
MessagesScreenappliesimePadding()at its outermost container for Android’s edge-to-edge window. The iOS KMP host preserves SwiftUI’s keyboard-reduced viewport. The channel editor is a screen-owned overlay rendered after list actions and FABs; only its field region scrolls, while the populated action row remains outside that scroll region and above the IME. A row-local overlay is invalid because later siblings can paint over it even when the platform viewport is correct. - The resting Messages create control is anchored above the platform bottom safe area through the shared inset helper. It keeps one primary action and does not change the native shell’s navigation ownership.
- Composer rule: one box, topic field above it (pre-filled in topic context). Never block sending on topic choice paralysis — default suggestion = most recent topic.
- The flag-as-signal affordance is quiet (long-press / hover menu), not a toolbar button — it’s for the moment someone says “việc này phải cho sếp biết”.
- States: empty channel teaches topics by example; unread badges per §2; offline/failed send keeps the draft (invariant 8: never lose typed text).
- Live-tail reading position is explicit: while the reader is at the latest message, a remote update follows the tail; while the reader is inspecting older history, the viewport stays put and an accessible New messages count action appears above the composer. Activating it clears the count and jumps to the latest message. Historical search context remains pinned until the reader chooses Jump to latest.
- Native search is part of the Messages directory, not a detached global result dump. It searches the recent 90-day window by default, exposes an explicit all-history choice, retains the query on retry, opens the exact channel/topic or DM context, highlights the target, and labels that bounded context with a one-tap Jump to latest recovery action.
- Team/project detail embeds only a compact linked-channel list. Selecting a row routes to this canonical thread screen; no second message-thread implementation is allowed. Linked rows never show Join/Leave, and additional channel creation starts from the parent detail.
- Native Messages directory rows keep channel names on one primary line and combine description with inherited team/project context into one ellipsized secondary line. When a channel has live activity, that line leads with the bounded latest-message preview (or attachment label) and keeps the existing description/parent context after it. Timestamps use message activity and fall back to channel creation; unread counts remain in the trailing column, so the directory answers “what needs attention?” without opening every channel or pushing the next row below the fold.
- Web channel rows use the same server-owned activity projection and one-line hierarchy, preserving the existing parent label and unread badge while avoiding a second channel-directory data path.
- Native DM directory rows keep the participant title to one line, use the newest visible message timestamp (creation time only before the first live message), and show one concise preview line. Deleted messages are excluded from the projection; attachment-only messages use the localized attachment label. The row remains one tap to the same thread and its accessible label includes the preview when present.
- The native thread header exposes a single, labeled member affordance. It opens a calm full-height
roster sheet: identity first, role second, with Add member only when
can_manageis true. Removal is a trailing destructive action with confirmation. Linked channels show their inherited roster read-only and route governance to the parent instead of duplicating ACL controls. A failed organization-directory read stays visible with Retry; it must not masquerade as an empty list. Removal suspends the existingChannelMemberrow so its unread watermark survives reactivation; self-removal and removal of the final private-channel manager fail closed. - The composer owns attachment selection beside the existing Ask Lumi affordance. Selected files appear in one horizontal draft rail above the writing well with clear progress/error/remove states; sent attachments render as compact evidence rows inside the bubble, not decorative cards. Draft and in-flight attachment state is scoped to the selected thread and is discarded on a channel/conversation switch. Native reads stop at the 25 MiB boundary before upload; abandoned message-attachment drafts remain recoverable for 24 hours before cleanup.
- Web uses the same private upload contract: the attachment picker is optional beside the writing well, supports up to ten safe files of at most 25 MiB each, accepts both picker and visible drag-and-drop selection, allows attachment-only sends, and keeps failed files visible for removal/retry. A sent row contains filename, type-derived icon, and size only; clicking it asks the API for a fresh visibility-authorized URL rather than rendering a durable public link.
- Web New message supports checked multi-select of up to seven active teammates, makes the eight- participant cap visible, and keeps the selected names legible before creation. A single selection creates the existing 1:1 DM; two or more selections use the bounded group-DM request and render every participant name in the active thread header.
- Channel and DM bubbles keep existing aggregated reactions visible as count pills in the same compact footer as timestamp and actions. Empty messages render no reaction rail; Add reaction opens from the message action control and expands only the six emoji accepted by the server. Every control keeps a 48dp touch target while the visual pill stays compact, tombstones expose no reaction action, duplicate taps on one message are disabled in flight, and success reconciles the exact server-returned message rather than incrementing a speculative local count. Failure preserves the message and stays visible.
- i18n:
messagesnamespace, en+vi. Naming: nav label “Tin nhắn”; F14 stays “Hỏi Lumi” — two different mental slots, never both called “chat” in the UI.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Channel CRUD, reversible archive/unarchive, join/leave, private ACL (non-member: 404, list-excluded) | backend | new classes in backend/tests/ |
| 2 | DM visibility: non-participant blocked; unique 1:1 pair | backend | same — same severity as cross-tenant |
| 3 | Cross-tenant blocked on every endpoint | backend | mandatory |
| 4 | Topic grouping (digest from ChannelTopic, bumped transactionally on send), id-watermark unread math, mark-all-read | backend | same |
| 5 | Edit window, tombstone delete, reaction uniqueness | backend | same |
| 5b | Partition invariants (§4.1): keyset cursor pages correctly (no overlap/skip at boundaries); Reaction.message_created_at == message.created_at; watermark only advances; search 90-day window vs all=true | backend | same |
| 6 | flag-signal → EvidenceSource(chat_note) + optional extraction with evidence | backend | same |
| 7 | Search respects visibility | backend | same |
| 8 | SSE stream delivers only my visible conversations’ events — never another member’s DMs/private channels, never another tenant | backend | same — same severity as cross-tenant |
| 9 | Channel→topic→send→read flow; DM flow; mention badge; message appears without manual refresh (SSE or fallback) | e2e | new describe("Messages") |
| 10 | One-parent constraint, private flags, separate org-default/parent-default invariants, unique parent-default per container | backend | backend/tests/notifications/test_container_channels.py |
| 11 | Roster synchronization, removal revocation, and generic join/leave denial | backend | backend/tests/notifications/test_container_channels.py |
| 12 | Nested list/create authorization and cross-surface direct access return 404 | backend | F03/F04 P60 suites |
| 13 | Global Messages lists linked rows once with parent labels and routes governance to the parent | frontend/shared + Chromium/native | P60 C15/W04/M15 |
| 14 | Channel member directory/add/remove: tenant isolation, private visibility, manager allow, member/viewer deny, default/linked/self/final-manager protection, inactive-row watermark preservation, visible directory retry | backend + shared | backend/tests/notifications/test_messaging_channel_members.py; shared screen-model tests |
| 14b | Linked-channel member directory remains visibly inherited/read-only on native: roster is readable, explanatory state is announced, and no direct Add/Remove action exists | shared + Android runtime | shared source contract plus the lifecycle-owned roster Maestro suite |
| 15 | Message attachment custody: private presign, same-tenant completed upload, message-visibility download, retry/draft preservation, thread isolation, 24-hour orphan retention, bounded count/size/type and native read | backend + shared + native | F29 completeness plan and guarded Maestro proof |
| 15b | Native direct-message attachment send: a participant selects a real document, sends an attachment-only DM, and sees server-returned filename/size metadata without crossing thread custody | shared + Android runtime | same lifecycle-owned private-storage runner and exact database custody readback |
| 15c | iOS channel and DM attachment taps request a fresh visibility-authorized download URL and forward only HTTP(S) URLs to the system opener | shared + iOS | exported KMP callback source guard plus focused XCTest |
| 15d | Web attachment picker: private purpose, text-plus-file and attachment-only send, retry-preserved draft, metadata row, authorized download callback, and bounded validation | frontend + backend | frontend/src/test/connect/MessageAttachments.test.tsx plus focused API custody tests; authenticated browser proof remains open |
| 16 | Native contextual reply: same-topic/channel or same-DM validation, batched parent preview, reply/cancel/retry state, persisted quote rendering, and lifecycle send/readback | backend + shared + Android runtime | F29 completeness plan and guarded Messages Maestro runner |
| 17 | Native explicit signal capture: exact non-extracting request, pending/already-captured guard, concurrent-state-safe reconciliation, quiet accessible overflow action, and lifecycle EvidenceSource/AuditLog readback | backend + shared + Android runtime | F29 completeness plan and guarded Messages Maestro runner |
| 18 | Native unread truth: successful channel/DM loads advance the exact newest visible UUIDv7 watermark, reject cross-container message ids, suppress stale/duplicate requests, preserve readable history on failure, expose exact Retry, and surface the aggregate on the Messages shell badge without borrowing the Notifications count | backend + shared + Android runtime | F29 completeness plan and guarded Messages Maestro runner |
| 19 | Native live-tail reading position: follow updates at the latest edge, preserve an older viewport with an accessible New messages count, follow the current member’s own send, and keep historical search context pinned | shared + Android/iOS runtime | shared JVM scroll-policy tests plus the lifecycle-owned Messages Maestro runner |
| 20 | DM directory recent activity: tenant-safe latest-message projection, newest-first ordering, markdown-free bounded preview, attachment-only label, tombstone exclusion, and native one-line row semantics | backend + shared JVM/source + Android runtime | test_messaging_conversations.py; ConversationRow source/presentation tests; extend the lifecycle-owned Messages directory proof |
8. Current Behavior
Shipped foundation. Internal human chat exists in the messaging app with channels, topics, and
DMs, and remains explicitly separate from F14’s Ask-Lumi conversational surface.
Channel now has an optional single team or project parent, a separate protected
is_parent_default marker, and materialized ChannelMember.is_active access rows. Parent create,
roster mutation, suspension/reactivation, ownership transfer, and deletion run through explicit
transactional services; generic join/leave cannot mutate inherited membership. Search, SSE,
notifications, unread state, reactions, indexing, and assistant follow-ups all exclude inactive
linked-channel members. Nested parent routes own linked-channel creation and lifecycle
authorization, with a hard 50-channel limit and no orphan ACL after parent deletion. Shared
Android/iOS parent details now open the canonical thread with an exact typed channel-id route and
parent-aware Back order; the P60 E01–E08 suite passed on the approved emulator and simulator. The
global web directory, macOS, named-device, native screen-reader-service, and store proof remain
later P60 slices.
Production linked-channel writes default enabled. CONTAINER_CHANNELS_ENABLED=0 is retained only
as an explicit emergency rollback control; normal deployments and fresh environments must not
silently expose channel-creation controls backed by a disabled endpoint.
Ordinary channel and DM message pushes retain a hidden route-bearing compatibility event and
PushLog delivery attempts, while ChannelMember.last_read_message_id and
ConversationMember.last_read_message_id remain the server-authoritative unread sources. The
member notification center excludes legacy NotificationEvent(category="message") rows from its
feed, read mutation, mark-all mutation, and unread aggregate; direct mentions remain actionable
notification events. Android and iOS independently aggregate channel plus conversation unread
projections for the Messages icon.
9. Error States
| # | Error | Response | Notes |
|---|---|---|---|
| ES1 | POST to private channel as non-member — §5 says GET returns 404 for non-members; POST behavior not explicitly stated | Gap: 404 vs 403 for non-member POST is unspecified | No dedicated test for this case |
| ES2 | Edit after 15-minute window — server rejects edit past window | Error response (status code, message key) not defined in spec | Edit window enforced server-side; no test for expired-edit error shape |
| ES3 | Tombstone rendering after edit — message edited then deleted | Gap: whether tombstone shows “(đã xóa)” with or without “(đã sửa)” is unspecified | (đã sửa) emitted at serializer level; tombstone takes precedence |
| ES4 | SSE stream disconnection + catch-up — prolonged disconnection (>5 min) misses events | Client reconnects → must fetch missed messages since last-known watermark | Catch-up logic duration and event-loss window not detailed |
| ES5 | Search from non-member of private channel — visibility-scoped search (§5, §7) | Gap: whether non-member search returns 403 or silently omits results | No explicit test for omit-vs-403 behavior |
| ES6 | Duplicate 1:1 DM creation — unique pair enforced via normalized participant hash (§4) | Gap: 409 or 200+existing for duplicate-1:1 upsert not specified | unique 1:1 pair enforced per §4 but upsert vs 409 not stated |
| ES7 | @name mentions deactivated member — mentions ArrayField stores member UUIDs | Gap: mention resolution for departed members not described | No test for deactivated-member mention |
| ES8 | Channel name collision (case) — Channel.name unique per org; case normalization unspecified | Gap: case-sensitive or case-insensitive unique constraint not stated | Vietnamese names may confuse on “Chung” vs “chung” |
| ES9 | Mark-all-read with data race — watermark only advances forward; “mark all read” to channel’s latest id | Gap: mark-all-read race-safety not explicitly tested | Watermark must not regress if latest id is somehow behind current |
| ES10 | flag-signal on DM message — participant flags a private DM message as signal | 201 with evidence_id; same visibility rules as DM read access | DMs are flaggable by participants; evidence is still copied as chat_note without an FK into Message |
| ES11 | Linked channel direct join/leave | 400 membership_inherited | Change the parent roster instead |
| ES12 | Linked default archive/delete | 400 default_channel_required | Parent retains one ready conversation |
| ES13 | Linked channel with both parents or a foreign parent | database/serializer rejection | Fail closed before any membership is written |
| ES14 | Delete linked parent | Explicit confirmation names affected channels; parent deletion cascades linked channels/messages and never leaves an orphan ACL | |
| ES15 | Create 51st linked channel | 409 linked_channel_limit; no row or membership change | |
| ES16 | Linked row in global Messages | Parent label + open-thread/open-parent actions only; no direct membership or lifecycle mutation |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md (ER) · frontend/DIAGRAMS.md (route) · DIAGRAMS.md (chat→evidence ingress) · GLOSSARY (Channel/Topic/Conversation + chat disambiguation) · F14 spec (relationship note) · F21 spec (mention digests) · F28 spec (announcement discussion-topic link, §10).
11. Implementation Audit (2026-07-14)
The canonical Contract Graph declaration is contracts/features/F29.cue. The current code-vs-doc audit is F29-internal-chat-audit.md. The API-focused contract owns the 26 messaging endpoints and their collected tenant/role/pagination, search, lifecycle, and SSE proof targets. Browser, native, and live SSE runtime evidence remain explicit gaps; this slice does not infer them from source or mocked Redis tests.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Channels, topics, DMs; default #chung seeded per org; private ACL via ChannelMember row existence | ✅ match | P07 PR-1; ensure_default_channel() in create_organization(); private-channel tests |
| 2 | Topic digest from ChannelTopic table (not GROUP BY over messages); transactional bump on message send | ✅ match | P07 PR-2; ChannelTopic maintained in same transaction as message write |
| 3 | SSE push + 30s polling fallback; heartbeat ~25s; per-user event filter (never cross-member/cross-tenant broadcast) | ✅ match | P07 PR-3; GET /api/messages/events/; §8 test row for SSE isolation |
| 4 | Keyset pagination: UUIDv7 cursor, before= param, no OFFSET; composite indexes (channel, topic, id DESC) | ✅ match | P07 PR-4; §4.1 rules enforced from first migration |
| 5 | flag-signal → EvidenceSource(chat_note) with message text copied (not FK-referenced) | ✅ match | messaging/views.py line 330 (MessageFlagSignalView); evidence survives retention by design |
| 6 | Edit window 15 min server-side; tombstone delete sets is_deleted=True and body_md="tin nhắn đã xóa"; emoji restricted to standard set | ✅ match | Fleet C r23: is_deleted BooleanField + tombstone body + STANDARD_EMOJI validation; 7 xfail tests pass |
| 7 | SSE stream isolation: only my visible conversations’ events — DM/private channel excluded for non-participants | ✅ match | §7 test row 8; non-member DM events filtered server-side |
| 8 | Group DM max 8 participants enforced in messaging/services.py | ✅ match | GROUP_DM_MAX_PARTICIPANTS = 8 defined at services.py line 16; enforced at lines 63-64 |
| 9 | Topic name max 60 chars: API rejects oversize topic names with 400 before write | ✅ match | messaging/views.py explicit length guard; 2026-07-01 test_post_to_channel_rejects_topic_longer_than_60_chars |
| 10 | flag-signal applies to DM messages visible to the participant | ✅ match | TestFlagSignal::test_flag_dm_message_creates_evidence; TestIsFlaggedField::test_conversation_messages_get_reflects_flag_state; flag_message_as_signal() builds DM permalink/title when message.channel_id is null |
Summary: 10/10 match. Topic-length proof closed earlier on 2026-07-01; DM flag-as-signal was already shipped and is now documented accurately.
Reference Study (Zulip, audited 2026-06-10): Adopted: topic on every channel message (≤60 chars); stream privacy tiers (public / private-shared-history); topic-grouped catch-up as primary reading mode; tombstone-style deletion; monotonic message ids as ordering/anchor primitive (UUIDv7 plays the monotonic-id role). Deliberately rejected: Recipient indirection (simpler to tenant-scope with two explicit FKs); per-user-per-message UserMessage flag rows (watermarks suffice at ≤200 seats); presence/typing indicators (async-first culture); bots/integrations platform, custom emoji, drafts/scheduled messages, threads UI; realm/multi-realm machinery (Lumi already has Organization tenancy).
Edge Cases Catalog (formerly §13, 2026-06-12; 10 items): Key items integrated above. Remaining: EC1 no topics ever created (empty topic digest; initial-list behavior for seeded-but-empty channel unspecified); EC3 empty or whitespace-only message body (minimum-content validation unspecified); EC4 CEO in 20+ channels — large SSE initial snapshot unbound; EC5 @channel with 200 members — 200 notification entries (digest discipline applies per F21); EC7 search matching 10,000 messages — search result pagination not defined; EC8 DM with 8 participants then 9th invited — enforcement boundary unspecified; EC9 reaction flood — no max-reactions-per-message constraint; EC10 simultaneous send on two devices creates divergent watermarks — self-message unread state across devices. Full catalog in git history.
12. Open Questions
- Images. Image files use the same private
message_attachmentboundary as PDFs and text files, with no durable public URL. Native channel and direct-message threads now resolve a short-lived authorized URL for visible image attachments and render an inline thumbnail while retaining the metadata/open fallback. The URL remains in active-thread memory only and is discarded on context changes; no second upload mechanism or weaker visibility authorization is introduced. - F14 convergence: should “Hỏi Lumi” eventually be a DM with Lumi inside
/hub/messages? Architecturally clean endgame; defer until F29 proves retention. - F28 glue: auto-create a discussion topic per announcement (keeps F28 comment-free while giving discussion a home)? Decide when both ship.
13. Bug / Change Log
2026-08-27 — Message unread state no longer borrows the notification center
Type: Bug fix / cross-platform unread truth (issue #108)
The generic push helper previously created a notification-center event for every ordinary message.
That made the Notifications badge/count grow while the Messages icon stayed unbadged. Message
delivery now keeps its route-bearing compatibility event and PushLog telemetry while the F29
channel/DM watermarks own unread state. Legacy and new message events are filtered from notification
APIs and their aggregate is reconciled by migration;
mentions continue to create notification events so the actionable signal is not lost. Android and
iOS shell refreshes sum both channel and conversation unread projections, and provider/native
payloads request the default sound.
Repository proof is registered under F77.AC007.*; provider, named-device, and store evidence
remain separate.
2026-08-26 — Keep one title owner on iOS Messages threads (issue #114)
Type: iOS navigation-title ownership fix (F29 Messages)
Summary:
- The shared KMP Messages host owns the localized
Tin nhắndirectory title and hides that directory header while a channel or conversation thread is selected. - iOS clears the native route title for the Messages host so the shared KMP thread header owns the participant or channel title exactly once.
- Android, routing, Back behavior, and message data remain unchanged.
Tests:
MessagesNavigationTitleSourceTest.iosMessagesHostUsesSingleTitleOwnerlocks the list/thread ownership split and the dynamic shared headers.
Evidence boundary:
- Source proof closes the duplicate-title ownership contract; the guarded iOS Messages lifecycle, TestFlight, named-device, and deployed evidence remain separate release gates.
2026-08-28 — Make Messages search prefix-aware per word (issue #113)
Type: Search behavior fix (backend PostgreSQL + SQLite fallback)
Summary:
- PostgreSQL message search now builds a safe raw
lumi_vi_unaccentquery with one:*prefix term per normalized word, preserving all-term matching while allowingThato findThảo. - The SQLite fallback uses the same normalized token/prefix semantics, so local and production search agree on punctuation and multi-word queries without changing tenant, visibility, date, channel, sender, or 50-result boundaries.
Tests:
- Backend prefix-query construction, SQLite multi-word prefix matching, and a PostgreSQL-gated accented-prefix integration regression pass.
Evidence boundary:
- Database/query semantics are covered by backend tests; deployed/provider and named-device search journeys remain separate release evidence.
2026-08-25 — Make Messages search accent-insensitive and useful from one character (issues #111, #112)
Type: Search behavior fix (backend + shared Android/iOS state)
Summary:
- PostgreSQL message search now uses Lumi’s existing
lumi_vi_unaccentconfiguration for tone-insensitive topic/body matching; the SQLite fallback folds Unicode Vietnamese marks and case while preserving the same visibility, tenant, date, channel, sender, and 50-result rules. - Native search now sends any non-whitespace one-character query; blank and whitespace-only input remains a no-op, while the web surface already follows the same non-blank boundary.
Tests:
- Backend
TestSearch.test_search_ignores_vietnamese_tone_marks,TestSearch.test_search_accepts_one_character_query, and the SQLite page-cap regression pass. - Shared
MessagesScreenModelSearchTest.searchesOneCharacterQueryandMessagesSearchSourceTest.messageSearchEnablesOneCharacterQueriespass; full shared JVM and backend coverage gates pass.
Evidence boundary:
- The change proves query semantics and tenant-safe filtering in tests; deployed/provider and named-device search journeys remain separate release evidence.
2026-08-25 — Keep Messages create action above bottom navigation (issue #110)
Type: Mobile UX reliability fix (Android + iOS KMP)
Summary:
- The resting Messages create action now owns the shared platform bottom safe-area inset before expanding into New message/New channel actions.
- Shell navigation ownership, role gating, routes, and the single primary create control remain unchanged.
Tests:
MessagesSafeAreaSourceTest.messagesDirectoryCreateActionOwnsBottomSafeArea.- Critical Messages create flow captures a settled list screenshot before tapping the control.
Evidence boundary:
- Shared JVM/source proof does not close named-device or store geometry; those remain release gaps.
2026-08-15 — Materialise organizations before the mention digest loop
Type: Production worker reliability fix
Summary:
send_mention_digest_for_timepreviously usedOrganization.objects.iterator()while each organization performed message, membership, and Telegram work. The dev worker retried this beat task with PostgreSQLInvalidCursorName/cursor ... already existserrors.- The digest now snapshots the bounded organization list before downstream work, and a regression
test fails if it reintroduces
QuerySet.iterator().
Evidence boundary:
lumi-dev-celery-workerlogged mention-digest cursor retries at the container-local 17:15 and 17:30 ticks on 2026-08-15. This is live log plus source/test evidence, not proof of a future Telegram delivery after the new image rolls.
2026-08-11 — Keep message metadata compact and release stale reply anchors
Type: Mobile UX and navigation regression fix (F29 Messages)
Summary:
- Native channel and direct-message rows rendered an empty add-reaction control and a separate action row for every message. The highlight background covered that full-width empty structure, producing large gaps and an oversized selected region.
- Existing reactions, timestamp, and the 48 dp action control now share one compact footer. The six-emoji picker appears only after Add reaction is chosen from the action control, and the selected outline belongs to the message bubble rather than empty row width.
- A reply-parent highlight is now consumed only by initial/context navigation or an actual target change. A later successful local send follows the new tail instead of returning to the stale parent.
Evidence:
MessageReactionSourceTestpins the resting-state/footer contract and shared channel/DM wiring.MessageThreadScrollPolicyTest.ownSendDoesNotReuseAnUnchangedReplyHighlightAsItsScrollTargetpins the stale-anchor failure. Store and named-device evidence remain separate release proof.
2026-08-09 — Restore compact reaction paint on narrow phones
Type: Mobile UX density regression fix (F29 Messages)
Summary:
- A later Compose measurement repair restored the propagated-minimum reset but left the painted reaction surface at 28dp, so the six-reaction rail still read as a large toolbar on a narrow phone even though its 48dp semantic target was correct.
- The painted surface is back to a 24dp minimum. The invisible 48dp touch target, six-emoji boundary, selected state, localized labels, and server reconciliation are unchanged.
Evidence:
MessageReactionSourceTestpins the 24dp painted surface and shared channel/DM wiring; the existing reaction model tests continue to cover server reconciliation, failure preservation, and duplicate-tap suppression.- Fresh guarded Android/iOS Messages lifecycles cover the shared surface, but do not make a pixel-dimension or named-device accessibility claim. Those remain explicit proof gaps.
2026-08-08 — Keep native attachment previews visible and composers above Android navigation
Type: Mobile UX reliability fix (F29 Messages)
- Android channel and direct-message roots now own both the shared IME inset and the Android bottom-safe-area inset, so the composer/tool row is not covered by system navigation bars.
- Visible native image attachments resolve the existing short-lived authorized download URL and render an inline thumbnail. Metadata and the authorized open action remain the fallback, and preview URLs are discarded when the member changes thread or topic.
F29.AC015.NATIVE_INLINE_IMAGE_STATE,F29.AC015.NATIVE_INLINE_IMAGE_UI, andF29.AC025.NATIVE_SAFE_AREAcover the state and source contracts. Named-device, deployed, and store-binary evidence remain separate release gates.
2026-08-05 — Re-verify the complete iOS Messages lifecycle on current main
Type: Runtime evidence / Messages completeness (F29)
The approved iOS simulator now has one current-main, fixture-owned lifecycle covering public channel participation, ordinary roster add/remove, linked-channel read-only roster behavior, per-channel notification mode reconciliation, bounded group-DM creation/member presentation, and the exact notification message doorway. The existing compact header, reaction, live-retry, and attachment surfaces were exercised without adding a parallel interaction or a new route.
Evidence:
scripts/run-ios-messages-roster-proof.shpassed all six flows in one Maestro process on build185, installed SHA-2567391f2b40350f21017916ab6d275bc677980c786b7be30509a1966259d59b495, simulator0A8C774B-C1D3-4A43-816C-81D3D3D849D8; group-DM custody was1:3:1:1and the simulator shut down cleanly.- The retained command, identity, timing, custody, and evidence boundary are recorded in the 2026-08-05 iOS runtime record.
This is managed-simulator evidence only. Named-device visual/accessibility, provider, store, deployed/live transport, and production proof remain separate gaps.
2026-08-05 — Make New message member search accent-insensitive
Type: Mobile bug fix (F29 Messages)
Summary:
- New message search previously used Kotlin’s case-insensitive
contains, which did not fold Vietnamese tone marks. A query such asCotherefore missed an active member namedCỏ. - Shared search normalization now folds Vietnamese diacritics and case for both names and email addresses while preserving the existing active-member, system-member, and tenant-scoped list.
- The 2026-08-10 tester retest exposed a second defect: matching the entire hidden email made
comatch unrelated.comdomains. Plain queries now inspect only the email local part; full-address matching activates only after the user types@. - Named member rows now show the email as subdued secondary identity, so an email-local-part match explains why the row is present and duplicate names remain distinguishable. Email-only rows stay single-line.
Evidence:
CreateConversationSelectionTest.memberSearchMatchesVietnameseDiacriticsWithoutChangingEmailSearchfails before the corrected member matcher exists and passes withCỏ/Co, uppercase matching, local-part and explicit full-email matching, plus unrelated.comdomain negative cases.CreateConversationSelectionTest.namedMembersExposeTheirEmailAsSecondaryIdentitycovers the named-member secondary line and the email-only fallback.- The live Android/iOS creation-screen runtime remains a separate deployed/device proof.
2026-08-05 — Navigate from a reply preview to its original message
Type: Mobile bug fix (F29 Messages)
Summary:
- Reply previews rendered the server-provided sender and excerpt but discarded the persisted parent id, so tapping the quoted context could not return the reader to the original message.
- Shared channel and direct-message previews now expose one localized, semantic 48 dp button. The
existing
aroundmessage endpoint loads the same-thread context and highlights the exact parent; a deleted, stale, or unavailable parent is an explicit no-op that leaves the current thread intact.
Evidence:
MessagesScreenModelReplyTest.channelReplyPreviewLoadsAndHighlightsTheReferencedMessageanddirectReplyPreviewLoadsAndHighlightsTheReferencedMessageverify channel/DM context loading and exact highlight state.missingReplyPreviewParentLeavesTheCurrentThreadUnchangedverifies the fail-closed boundary.MessagesReplySourceTestverifies the callback wiring, semantic role, 48 dp touch target, and stable Maestro test tag. Named-device, screen-reader-service, and live deployed runtime proof remain explicit gaps.
2026-08-05 — Persist native creation routes safely across process recreation
Type: Android navigation reliability fix (F29 Messages)
Summary:
- Navigation3 persists
NavKeystate through its saved-state encoder. The four native creation routes (New channel, contact, conversation, and team) were missing thekotlinx.serialization.Serializableannotation, so opening New message could raise a fatalSerializer for class 'CreateConversationNavKey' is not foundexception before the screen rendered. - Each creation route now has an explicit serializer annotation. No route identity, authorization, API payload, or back-stack behavior changes; the fix only makes the existing keys safe to encode.
Evidence:
MessagesConversationSourceTest.createRoutesAreSerializableForNavigation3Statefails on the pre-fix source and passes after the annotations are restored. The Android runtime route and process-recreation proof remain separate from this source regression.
2026-08-06 — Keep Messages controls compact without losing recovery
Type: Mobile UX density and live-refresh reliability follow-up (F29 Messages)
Summary:
- Reaction controls now separate the painted chip from its 48dp accessibility target. The 28dp visual surface stays readable for emoji and counts without letting the propagated minimum constraint inflate every chip to a full button.
- Channel notification mode remains an icon-only header action: the accessible label names the current All/Mentions/Muted mode and tapping it opens the existing detail sheet. Leave remains an icon-only action with the localized semantic label.
- A failed live catch-up keeps the last readable channel or DM messages in place and shows one compact retry row. Retry therefore re-reads the selected thread instead of appearing to do nothing behind a blank error state; the degraded-live banner remains one line.
Evidence target:
MessageReactionSourceTest,MessagesNotificationModeSourceTest, andMessagesLiveUpdatesSourceTestcover painted density, icon-only semantics, stale-content retry, and stable selectors. Shared live-update tests continue to cover immediate catch-up, preserved search context, duplicate suppression, and stale observer fencing.- Android/iOS runtime, controlled outage, named-device, accessibility-service, provider, and store-binary evidence remain explicit gaps until the lifecycle-owned runners execute again.
2026-08-05 — Channel directory surfaces the latest visible activity
Type: Messages UX completeness slice (F29)
Summary:
- Channel rows previously showed only the channel creation timestamp and description, while DM rows already showed recent activity. A busy channel therefore looked indistinguishable from an idle one until the member opened it.
- The channel list now projects the newest live message with the same bounded markdown-free
normalization used by the DM directory, identifies attachment-only activity, excludes tombstones,
and preserves
created_atfor empty channels. Android and web render the projection as one ellipsized secondary line without changing the existing tap route or governance controls.
Evidence target:
- Backend tests cover newest ordering, markdown/length bounds, attachment-only activity, tombstone exclusion, and tenant-scoped visibility. DTO/shared/web tests cover additive decoding, one-line hierarchy, accessibility text, and activity-time fallback. The existing guarded Android roster/attachment lifecycle remains the runtime vehicle; named-device, screen-reader-service, live-provider, and store-binary evidence remain explicit gaps.
2026-08-05 — DM directory surfaces recent activity without adding another route
Type: Messages UX completeness slice (F29)
Summary:
- The native DM directory used
Conversation.created_atfor every timestamp and had no preview, so active conversations looked stale and required opening each thread to find the latest signal. - The conversation list now projects only the latest live message visible to the participant, orders by that activity, strips markdown into a bounded single line, and identifies attachment-only messages without exposing tombstoned body text. Empty conversations retain their creation-time fallback.
- Native rows keep the existing direct/group identity and tap route while adding one compact preview line and an accessible description; no persistent fields or new messaging surface were introduced.
Evidence target:
- Backend integration covers ordering, preview normalization, attachment-only projection, and deleted-message exclusion. Shared tests cover DTO compatibility and compact row semantics.
- The existing guarded Android Messages lifecycle remains the runtime proof vehicle; named-device, native screen-reader-service, live provider, and store-binary evidence remain explicit gaps.
2026-08-05 — Explicit live-update Retry refreshes preserved search context
Type: Mobile UX reliability follow-up (F29 Messages)
Summary:
- The degraded-live Retry action restarted the SSE observer and refreshed the latest thread, but intentionally skipped message reads while a member was viewing a bounded search context. That made a valid tap look inert on the exact screen where recovery was requested.
- Retry now performs one authoritative read of the selected channel or DM in both latest and historical modes, passing the existing highlighted message id when context is pinned. Ordinary live invalidations still leave historical context untouched.
Evidence:
MessagesScreenModelLiveUpdatesTest.explicitRetryReloadsTheSelectedSearchContextproves the exact anchor is reloaded without dropping the context state.- The F29 contract and test plan register
F29.AC026.RETRY_CONTEXT; controlled outage, named device, accessibility-service, and production transport proof remain open.
2026-08-05 — Native send failures expose a real Retry action
Type: Mobile UX reliability fix (F29 Messages)
Summary:
- Channel and direct-message sends already restored the failed text and attachment state, but the thread rendered only the error copy. A member could press Send again manually, while the visible failure offered no recoverable action and made Retry appear broken.
- The shared model now marks only an untouched failed payload as retryable. Both native thread composers show one compact, one-line Retry action beside the error; retry reuses the preserved text, attachment ids, and reply target, and a successful send clears the retry state. Editing reply context or attachments clears Retry instead of silently sending a changed payload.
Evidence:
MessagesScreenModelTest.failedChannelSendPreservesTheComposerAndExplicitRetryResendsItandMessagesScreenModelConversationTest.failedDirectSendPreservesTheComposerAndExplicitRetryResendsItcover both targets.MessagesSendRecoverySourceTest.channelAndDirectThreadsExposeAnExplicitRetryForPreservedSendDraftsprotects the two accessible UI actions. Named-device, screen-reader-service, and store proof remain separate gaps; the existing guarded Android/iOS Messages lifecycles cover the surrounding composer and thread routes.
2026-08-04 UTC — Re-verify the compact Messages contract on current Android and iOS source
The requested narrow-phone corrections are present in the current main source and were
re-verified without adding a parallel interaction:
- Leave is icon-only with the existing 48dp accessible target and localized semantic label.
- The channel notification mode is represented by an icon in the header; tapping it opens the existing detail sheet where All, Mentions, and Muted remain explicit.
- Degraded live delivery is a compact one-line status; Retry performs an immediate catch-up and restarts the single generation-fenced observer, so a retryable outage cannot look inert.
- Reaction controls keep the 48dp target but paint compact chips, leaving the message width for content.
Evidence:
- The focused shared JVM/source regression set passed with the DTO contract gate: live retry, notification save retry, reaction density, and localized live-banner semantics.
- The guarded Android roster lifecycle passed flows 138, 128, 130, 141, and 142 in one Maestro
process against byte-identical prodDebug APK SHA-256
63fd075729b9c2c8fc1be32ef16cdbe4fed6fb17770c29da5022d34018cf69c6; separate flows 144, 145, and 146 passed exact cold, warm, and non-default-topic notification routes. - The approved iOS simulator
0A8C774B-C1D3-4A43-816C-81D3D3D849D8passed flows 138, 128, 130, 141, 142, and 143 sequentially in one Maestro process against build 184 SHA-2569dab5dccd710425548bda01c4ed091308637f6fbf8f563882cbe06d94217c1c0, then shut down cleanly.
The full command, identity, custody, and evidence boundary are retained in the current-source runtime record.
These are managed/disposable runtime results, not named-device, screen-reader-service, provider, store, or production transport proof. The runner does not retain screenshots after successful cleanup, so no new pixel-dimension claim is made here.
2026-08-04 — Make group DMs legible in the directory
Type: Mobile UX clarity refinement (F29 Messages)
Summary:
- One-to-one and bounded group DMs previously shared the same person glyph, so a member had to parse participant names to understand the conversation shape.
- Native directory rows now use a person glyph for 1:1 DMs and a group glyph for group DMs, while the actionable row exposes a localized type-first accessibility label. The existing route, participant ordering, unread count, and names remain unchanged.
Evidence:
ConversationMembersSourceTest.directoryRowsDistinguishDirectAndGroupConversationIdentityguards the branch, localized resources, and row semantics.F29MessagesRosterRuntimePolicyTest.test_group_dm_flow_proves_localized_group_row_identity_before_openkeeps the lifecycle flow’s accessible group label assertion and same-row tap route explicit.- The guarded Android group-DM lifecycle passed flow 142 in the same run as roster and notification custody; the visual glyph distinction is source/runtime evidence, not named-device accessibility service proof.
2026-08-04 — Reaction chips get a calmer narrow-phone appearance
Type: Mobile UX density refinement (F29 Messages)
Summary:
- The reaction rail keeps an accessible 48 dp semantic/layout target, but its 28 dp painted chips and inter-chip gaps still made six standard reactions read as a large toolbar on a narrow phone.
- Painted chips now use a 24 dp minimum with one-pixel vertical padding and no extra rail gap. The picker uses the same compact spacing; labels, emoji set, selected state, and server reconciliation are unchanged. The 48 dp slots remain intentional, so this change claims a calmer painted appearance rather than a smaller hit-target layout.
Evidence:
MessageReactionSourceTestprotects the 24 dp visual surface, 48 dp target, six-emoji boundary, and shared channel/DM wiring. The existing reaction model tests still cover reconciliation, failure preservation, and duplicate-tap suppression.- The guarded Android Messages lifecycle passed the reaction-toggle flow with APK SHA
60de12a4d6b86aa9239e7256009252585542eb7b1076d7f292ecd52844ef0ab6; the flow’s screenshot is runtime evidence for the painted state, not a pixel-dimension assertion. Named-device, native accessibility-service, and store-binary proof remain explicit gaps.
2026-08-04 — Follow-up: keep live retry state owned by the replacement observer
Type: Mobile UX reliability follow-up (F29 Messages)
Summary:
- Reaction controls now paint at a compact 32 dp minimum while retaining the invisible 48 dp accessibility target, and the degraded-live row uses tighter outer spacing around its one-line status and Retry action.
- Live observer state now carries a monotonically increasing generation. A cancelled stream that completes a late callback cannot clear or reapply the status belonging to a newer explicit Retry.
Reason:
- Coroutine cancellation is cooperative, so a transport callback can outlive the user action that replaced its observer. Without ownership fencing, Retry could appear to do nothing even though the authoritative catch-up and replacement observer had started.
Tests:
MessagesScreenModelLiveUpdatesTest.explicitRetryRestoresLiveUpdatesAfterTheStreamFallsBackasserts that a stale generation cannot erase the retry state.MessageReactionSourceTestandMessagesLiveUpdatesSourceTestprotect the compact visual surfaces while preserving the 48 dp touch-target contract.
2026-08-03 — Native Messages exposes live-update recovery state
Type: Mobile UX reliability (F29 Messages)
Summary:
- Channel and direct-message threads now show a localized, accessible status banner when the authenticated live stream has fallen back to bounded polling. Polling continues, so a transient SSE outage does not make the thread unreadable or require a manual refresh.
- One Retry action restarts the existing observer on its lifecycle scope. The action is disabled while reconnecting, and the banner clears after the next live event confirms recovery.
Reason:
- The previous fallback was silent. A member could reasonably believe Messages had stopped updating even though polling was still running, and repeated taps could have created overlapping observers.
Impact:
- No new workflow or form is introduced. Channel and direct-message transport, authorization, bounded polling, and reading-position behavior remain unchanged; only the trust state is visible.
Tests:
MessagesScreenModelLiveUpdatesTestcovers degraded fallback, explicit retry, duplicate-retry suppression, and recovery after an event.MessagesLiveUpdatesSourceTestcovers the shared banner, both thread hosts, and English/Vietnamese localized strings. Controlled outage, accessibility-service, named-device, and production transport evidence remain explicit Contract Graph gaps.
2026-08-03 — Channel member picker is searchable on mobile
Type: Mobile UX completeness (F29 Messages)
Summary:
- The ordinary-channel manager Add member sheet now searches the already-loaded active workspace directory by name or email, excludes system/inactive/already-rostered members, and keeps a stable name-then-ID order so the same person is easy to find on every open.
- An explicit localized empty state replaces a blank list when a query has no matches. The search field owns IME padding so the picker remains usable above the Android keyboard.
Reason:
- The previous picker rendered the full candidate directory with no filter. That is acceptable for a tiny fixture but makes a real SME workspace slow and error-prone as the roster grows.
Impact:
- Roster authorization, linked/default read-only governance, and the server-owned add/remove contract are unchanged. This is a bounded discovery improvement, not a new member role or channel-membership model.
Tests:
ChannelMembersSourceTest.memberPickerFiltersByNameOrEmailAndUsesDeterministicOrdercovers filtering, exclusions, sorting, and no-match behavior.- The guarded Android roster flow enters
Salesbefore adding the fixture member; named-device, iOS, browser, screen-reader-service, and production proof remain explicit gaps.
2026-08-03 — Native attachment opening gives the user honest progress
Type: Mobile UX reliability fix (F29 Messages)
Summary:
- Native sent-attachment rows now announce when a short-lived authorized URL is being requested, disable duplicate taps for that upload, and clear the pending state on both success and failure.
- A download callback is bound to the attachment context that initiated it. Switching channels or conversations clears pending download state and prevents a late response from opening a file in the wrong thread.
Reason:
- The previous row looked tappable but gave no feedback while the visibility check/download URL request was in flight. A slow network could invite repeated taps, and a late callback could launch an old thread’s file after the member had moved elsewhere.
Impact:
- Channel and 1:1/group-DM attachment reading becomes calm and recoverable without changing the private-upload or server-authorization contract. Upload drafts and attachment-only sends are unchanged.
Tests:
- Shared JVM model tests cover duplicate suppression, success/error cleanup, and thread-switch invalidation; a shared source test covers the localized accessible opening state in both native message bubbles.
- Existing guarded Messages Maestro attachment coverage remains the runtime proof for real file selection and server custody; named-device, accessibility-service, and production storage gaps remain explicit.
2026-08-03 — Native channel composer keeps the send topic explicit
- The shared channel composer now renders a compact, localized read-only topic context immediately above the writing surface. The selected topic remains controlled by the existing topic rail; this adds no second editor or required step.
- Blank or whitespace-only topic state is normalized to
general, keeping the visible destination honest while preserving the server-owned topic payload and existing low-friction send path. - The context row has a stable
message_composer_topic_contexttest tag and accessible localized label in English and Vietnamese. Direct messages are unchanged because they do not have channel topics. F29-TOPIC-01andF29.AC002.NATIVE_TOPIC_CONTEXTcover normalization and the native accessibility contract. On 2026-08-03, the guarded iOS lifecycle passed all five roster/DM/notification flows on approved UDID0A8C774B-C1D3-4A43-816C-81D3D3D849D8with build 164 SHA-256a4e6062d811b3efc62c2796d0fd7c3f540d7349de4954e59195f2dddedf27a63; flow 141 asserted the topic context before notification settings. This remains simulator-only evidence; named-device, screen-reader-service, provider, and production evidence remain explicit gaps.
2026-08-03 — Native channel notification mode is visible at a glance
- Selecting a joined channel now loads its server-owned notification preference before the settings sheet opens, while deduplicating the dialog’s follow-up request.
- The native channel header renders the localized All, Mentions, or Muted mode beside the title and uses the muted notification icon when applicable; the existing 48 dp button remains the edit surface.
- Non-member and linked-channel governance is unchanged. The Android lifecycle flow asserts the mode label after each immediate save and keeps the reconciled value visible; the runner’s final database readback proves server persistence.
- The focused shared JVM/source tests and one guarded Android emulator lifecycle prove this slice. The lifecycle passed all five related flows on
emulator-5554with prodDebug APK SHA-25661aa3147e45aeda97d1d4d3f5d38622ce7ea09a6f191e9f5da4fba5337acfb9f. Named-device, iOS, provider-delivery, and production readback remain explicit gaps.
2026-08-05 — Channel-directory previews keep one-line semantics stable
- The native channel directory keeps its compact, ellipsized activity preview, but now exposes a stable preview selector in addition to the row-level accessible label. A long preview must not make runtime proof depend on an exact string that is no longer a full visible node after ellipsis.
- The Android and iOS lifecycle runners pass the seeded ordinary-channel id into Maestro, so the same selector proves the preview belongs to the intended tenant fixture without changing tap routing or channel governance.
- The DTO-gated focused shared JVM tests passed. The guarded Android lifecycle passed flows 138,
128, 130, 141, and 142 in one Maestro process, plus exact cold, warm, and non-default-topic
notification routes 144, 145, and 146 on the same approved emulator. Built and installed
com.lumi.aiprodDebug APKs matched at SHA-256f2bfc75ce6944db52f678b50506a1741a0905164758f7232c6042f62b530c34c; final custody remained group-DM1:3:1:1, linked roster2:2, and public participation0:0:1. - This is managed Android-emulator evidence only. The approved iOS runner now carries the same fixture id, but named-device rendering, native screen-reader output, provider delivery, production transport, and store-binary proof remain explicit gaps.
2026-08-04 — Messages mobile density and live retry recovery
- The native channel header keeps the saved notification mode on the accessible notification icon and opens the existing detail sheet on tap; the redundant localized mode text and destructive Leave label are removed from the narrow header. Leave remains a 48 dp icon-only action with a localized content description and the existing confirmation step.
- Reaction pills keep their 48 dp accessible touch target but use a smaller visual surface so the picker no longer consumes the message width on phones.
- The live-update warning is intentionally one line and concise. Retry now performs an immediate tenant-scoped catch-up read before reconnecting SSE, and any confirmed stream frame (including a heartbeat) clears the degraded state. This makes Retry observable even when the transport remains degraded and prevents a healthy heartbeat from leaving a stale warning visible.
- Shared JVM/source tests cover the compact controls, immediate catch-up, heartbeat recovery, and
retry duplicate suppression. The updated guarded Android roster lifecycle passed all five flows
on
emulator-5554with prodDebug APK SHA-256b505b3438d343068cc4069848e7f01f7b10d398edd531c0983e3fe35222a0c3f; named-device, iOS, provider-delivery, and production readback remain explicit gaps.
2026-08-05 — Keep the compact thread controls recoverable
Type: Mobile UX density and live-transport recovery follow-up (F29 Messages)
Summary:
- Reaction pills now paint at a 20 dp minimum with tighter text padding while their parent keeps Material’s 48 dp interactive target. The header keeps Back, members, notification detail, and Leave as icon-only actions with 20 dp painted icons, so the narrow thread header saves visual weight without weakening screen-reader labels or tap reachability.
- The live-update row remains a concise one-line status and its Refresh action paints at 20 dp. Explicit Retry still performs the selected-thread catch-up before replacing the SSE observer, but a silent transport no longer leaves Retry disabled for the full 30-second watchdog: a generation-guarded five-second feedback window re-enables the action while polling/watchdog work continues.
Evidence:
MessageReactionSourceTest,MessagesNotificationModeSourceTest, andMessagesLiveUpdatesSourceTestguard the compact painted surfaces, icon-only semantics, and stable retry selector.MessagesScreenModelLiveUpdatesTest.retryReenablesAfterAQuietTransportFeedbackWindowproves that a quiet reconnect cannot make Retry look inert; existing tests retain immediate catch-up, preserved search context, heartbeat recovery, duplicate suppression, and stale-generation fencing.- Named-device, native accessibility-service, controlled outage, provider, production transport, and store-binary evidence remain explicit F29 gaps.
2026-08-04 — Per-channel notification Retry replays the failed save
- A failed
all/mentions/mutednotification-mode save no longer leaves the screen with a cached success that makes Retry a no-op. The previous confirmed mode remains readable while state retains the exact requested mode only for the failed save. - Retry now replays that one tenant-scoped mutation, then clears the retry intent and reconciles the server response. Load failures continue to retry through the existing preference read.
- Shared JVM state and source-contract tests cover the previous-mode display, exact mutation replay, and one-call success cleanup. Controlled provider outage, named-device, and production push evidence remain explicit gaps.
2026-08-04 — Recovered iOS Messages lifecycle proof
- After the notification-primer-aware login helper landed, the guarded iOS Messages runner
rebuilt and installed build 183 on approved UDID
0A8C774B-C1D3-4A43-816C-81D3D3D849D8with SHA-2563121d3e0623eb84b58c596fea620a5dfa60a92537a0abebcbf5726e2a83840ad. - One Maestro process passed all six flows sequentially: public-channel participation,
ordinary roster governance, linked-channel read-only roster, per-channel notification
settings, group-DM roster, and exact message-notification routing. Group-DM custody was
1:3:1:1, and the lifecycle runner shut the simulator down before releasing the lane. - This closes the previously blocked approved-simulator result only. Named Android/iPhone, native screen-reader-service, provider-delivery, live production, and store-binary evidence remain explicit gaps.
2026-08-04 — Corrected Android nine-flow Messages proof
- The first current-source Android run rendered the authenticated shell but every flow waited on
a stale
main_tab_viewselector because the V2 shell exposedmain_tab_view_v2. The shell now restores the sharedmain_tab_viewanchor; the selector drift and the detection gap are recorded in the shell-anchor postmortem. - The corrected, lifecycle-owned Pixel 7 API 35 run built and installed one byte-attested prodDebug
APK (
0d264781ef3aa0692ac68a7ebe31c6391bbb9fd2268f10fe7c2b2928e29e504d) and passed all nine Messages Maestro flows in one process: thread Back, live convergence, unread watermark, channel and direct-message private attachments, in-thread and workspace-global search, contextual reply, and explicit signal capture. Final custody was channel attachmentLaunch:True:1, DM attachmentTrue:1, replyLaunch:True:26080277 exact quarterly decision:ceo@demo.local, signal1:1:True:ceo@demo.local, unreadTrue:True:True, and live1:1. - The runner shut down the emulator and its backend/S3/Redis helpers before releasing the resource lane. This is current Android-emulator mechanics and tenant-fixture custody only; named Android, iPhone, native screen-reader-service, provider delivery, production transport, and store-binary evidence remain explicit gaps.
2026-08-04 — F29 attachment proof now fails closed on runtime shutdown
- The attachment runner previously sent
adb emu killbut returned before the owned emulator process had been reaped. The first current-source nine-flow run exposed that gap: the flow and custody assertions passed, but a manual emulator shutdown was required before the mobile lane was safe to reuse. - Cleanup now waits for the owned emulator PID, verifies the exact ADB serial disappears, retains artifacts, and returns nonzero if shutdown cannot be proven. External interruption is also nonzero (130/143), so a cancelled run cannot delete its evidence or masquerade as a passing lifecycle. The static runtime-policy test keeps the process wait, serial check, signal status, and fail-closed path reviewable.
- The corrected guarded rerun passed all nine flows in one Maestro process with byte-identical
prodDebug APK SHA-256
eb3736ebe11f05fcfb3b97683db57e41419a76310740c3a49cce7525e72d7742; channel, DM, reply, signal, read-watermark, and live custody matched the expected proof values. Immediate host readback found no Android serial, booted iOS simulator, qemu/emulator, backend, S3 shim, or Redis process. This closes runner-custody proof for the disposable Android lifecycle only; named-device, iOS, browser, provider, production transport, and store-binary evidence remain explicit gaps.
2026-08-04 — Android roster, notification, and group-DM lifecycle custody
- The Messages onboarding handoff now performs one optional dismissal of the retired legacy tour and then waits on the stable auth selector. It no longer polls an absent control repeatedly, which keeps a missing tour from turning every clean-flow launch into a long false failure.
- The Android roster runner rejects pre-existing
device,offline, orunauthorizedADB rows and an already-running qemu process for the selected AVD. Its cleanup waits for both the owned emulator PID and exact ADB serial to disappear; the attachment runner uses the same ownership fence. - On 2026-08-04, one guarded Pixel 7 API 35 lifecycle passed cold and warm notification routing,
public read/join/send/leave, ordinary add/remove roster, linked read-only roster, channel
notification save/reconcile, and ordered group-DM membership in one Maestro process. The
byte-attested prodDebug APK SHA-256 was
b2725a3366cd738f9a32e48e22c8ae7ae6419cbdb8e02b776d22d8181197ee20; group-DM custody was1:3:1:1, linked roster custody was2:2, and public participation custody was0:0:1. Immediate host readback found no Android serial, booted iOS simulator, qemu/emulator, or local backend process. This is disposable Android proof only; named-device, iOS, accessibility service, deployed transport, provider, production, and store-binary evidence remain gaps. - An earlier run was discarded after the emulator displayed a system “Lumi isn’t responding” dialog during the repeated onboarding probe. The proof does not treat that host/runtime ANR as a product fix or as evidence against the clean rerun.
2026-08-04 UTC — Native Messages directory rows keep context compact
Type: Mobile UX density refinement (F29 Messages)
Summary:
- Channel directory rows previously rendered the description and inherited team/project label as separate lines. On a 375px phone, that made the directory feel like a stack of cards even when the member only needed the channel name and one context cue.
- Rows now keep the channel name on one line and join the existing description/parent values into one ellipsized secondary line. Resting padding is reduced from 12dp to 8dp; timestamps, unread counts, swipe-governed management actions, and the row’s accessible activation target are unchanged. Archived rows use the same hierarchy.
Evidence:
MessagesChannelEditLayoutSourceTest.channelRowsKeepContextInOneConciseSecondaryLinecovers both-value, single-value, and empty-value normalization plus the one-line row contract.- The focused filtered shared JVM proof passed after the implementation. The guarded Android
roster lifecycle also passed flows 138, 128, 130, 141, and 142 in one emulator custody window
with matching built/installed prodDebug APK SHA-256
50e576fe75afd9465b805c7b6a8203abf1d03ab063642eda420e45b0f009a6ac; a fresh 375px pixel frame, named-device, iOS, screen-reader-service, and store evidence remain explicit gaps.
2026-08-04 UTC — SSE Retry acknowledges the connected frame immediately
- The live stream flushes a
: connectedcomment before waiting for its first periodic heartbeat. The shared client now treats that frame as a distinct healthy transport acknowledgment; unlike: ping, it does not imply that message state needs a catch-up read. - Before this fix, an explicit Retry could leave the compact row in its reconnecting state for up to 25 seconds even though the replacement stream was already accepted. No message payload or tenant scope changed; only recovery feedback became immediate.
MessagesRepositoryTest.connectedFrameConfirmsAReconnectedStreamImmediatelyprotects the wire parser, andMessagesScreenModelLiveUpdatesTest.connectedFrameRecoversWithoutAnUnnecessaryCatchUpReadprevents normal stream startup from causing a redundant refresh. The existing live-update state tests continue to cover catch-up, stale-generation fencing, and duplicate-retry suppression. Controlled outage, named-device, accessibility-service, and deployed transport proof remain explicit gaps.
2026-08-02 — iOS Messages attachment selection reaches shared upload custody
- The native iOS Messages hosts must provide the shared composer with a real file-selection callback; an authorized download opener alone does not make upload available.
- Directory-hosted, search-routed, and channel-routed Messages use one SwiftUI file importer and one KMP host bridge for channel and direct-message threads.
- The native reader preserves exact filename and MIME type, uses security-scoped access, and rejects a declared or observed size above 25 MiB before handing bytes to shared upload state.
- This upload slice requires real channel and direct-message attachment-only sends plus server attachment custody; source presence is insufficient. Authorized reopen/download remains a separate explicit runtime gap and is not claimed by these flows.
2026-08-02 — Web Messages attachment parity reaches private custody
- The web composer now uses the purpose-bound upload helper instead of exposing generic upload semantics to message sends. It accepts up to ten safe files, validates the 25 MiB limit before presign, and allows an attachment-only message.
- A failed send rolls back only the optimistic message; the text and uploaded attachment metadata remain in the composer so the member can retry or remove a file. A successful send clears the draft only after the API response is reconciled.
- Thread rows display compact filename/size metadata and call the visibility-checked download endpoint on demand. The browser route, accessibility output, private-object readback, and live download-open proof remain an explicit Contract Graph gap.
2026-08-02 — Native New message supports bounded group DMs
- The backend already accepted bounded
group_dmparticipant sets, but the native member picker created a 1:1 conversation as soon as the first teammate was tapped, making the shipped group-DM contract unreachable on Android and iOS. - The picker now keeps selection local until an explicit Create conversation action, renders checked rows and the selected teammate summary, and stops at seven selected teammates because the server adds the creator as the eighth participant.
- Group-DM directory rows now keep the participant set legible by showing up to three names and a localized “+N more” suffix; 1:1 rows retain the single-participant label.
- The existing tenant-scoped conversation endpoint remains the authorization boundary; this change only makes its existing group-DM capability reachable. Named-device, native accessibility-service, and store-binary proof remain open.
2026-08-02 — Native group-DM roster and durable signal confirmation
- Group-DM threads now expose a read-only members action beside the conversation title. The API preserves explicit selection order through UUIDv7 membership rows; native resolves those server-owned participant ids against the cached tenant directory, marks the current member, and keeps an honest fallback row when the directory is stale.
- Flag-as-signal confirmation now remains visible as a compact inline status chip after the transient
message-action tray closes. The chip is rendered only from the reconciled
is_flaggedstate, so a tap or pending request cannot imply that evidence was captured. - Shared JVM helper and source tests cover participant ordering/fallbacks, group-DM accessibility, and the durable captured-state affordance. Named-device and native accessibility-service proof remain explicit gaps.
2026-08-02 — Native group-DM lifecycle custody
- The lifecycle-owned Messages runner now selects two distinct seeded teammates before submitting
New message, opens the resulting group row, opens the read-only members sheet, verifies the three
participant rows plus the current-member marker, sends
26080322 group DM proof, and reads back exact ordered three-member participant custody plus the sender/message row from the disposable database. - The Android emulator and approved iOS simulator both run the flow through the same guarded, single-Maestro lifecycle as roster and channel-notification proof. Named Android/iPhone, native accessibility-service, and store-binary evidence remain explicit gaps.
2026-08-02 — iOS runtime custody for native channel participation and roster governance
- The shared roster and public-channel participation surfaces must run on the approved iPhone simulator, not remain Android-only evidence.
- One lifecycle-owned runner provisions an isolated tenant, clean-builds the current iOS source against that backend, runs public read/join/send/leave plus ordinary and linked roster flows sequentially through one Maestro driver, verifies exact database state, and shuts the simulator down.
- Ordinary-channel Add/Remove remains capability-gated; linked membership remains inherited and non-mutating; public leave preserves the inactive membership row and readable public history.
- Simulator evidence does not close named-iPhone, store-binary, or native accessibility-service gaps.
2026-08-11 — Native live-update warnings require prior health
- A newly opened Messages screen stays quiet while its first SSE connection is still unproven; fallback polling continues without presenting startup uncertainty as a user-actionable outage.
- Any server frame establishes live-update health for that screen lifecycle. A later watchdog or bounded-retry fallback then shows the existing localized paused state and Retry action.
- Retry, authoritative catch-up, generation fencing, stale-thread preservation, polling cadence,
and recovery behavior remain unchanged.
F29.AC026.ESTABLISHED_ONLYprotects the initial → established → degraded state boundary; controlled-outage and named-device evidence remain open.
2026-08-02 — Native public-channel participation lifecycle
- Discoverable public channels remain readable before joining, but native threads do not expose a
composer until server-owned
is_membertruth is active. One clear Join action replaces the exact channel with the response and enables participation without a directory reload. - Joined ordinary channels expose Leave behind explicit confirmation. Success returns to the
directory, preserves the public row with
is_member=false, and clears thread-only draft, attachment, reply, roster, and message state. Failure preserves readable state and offers localized retry/dismiss feedback. - Default, parent-default, and team/project-linked channels expose neither direct Join nor Leave; their membership remains governed by the organization or canonical parent.
- F29-MEM-11 through F29-MEM-15 bind shared wire/state/source behavior and the lifecycle-owned Android join → send → confirmed leave journey. iOS runtime, named-device, accessibility-service, and store-binary proof remain explicit gaps.
2026-08-02 — Native Messages converges without manual refresh
- Shared Android/iOS Messages now owns one cancellable SSE session for the screen lifecycle. It mints the existing short-lived single-use token and never places the long-lived access token in the stream URL.
- Each valid event is an invalidation only: the client re-reads tenant-authorized directories and the exact selected latest channel/topic or DM. It never applies event payload state directly and never moves a pinned historical-search context.
- The approved native engine reads the cancellable event stream through an ordinary streaming HTTP response. Both business frames and periodic server heartbeats trigger a catch-up refresh, while only a business frame confirms live recovery; a missed business frame is reconciled without opening a second concurrent poller.
- A failed stream gets three bounded attempts. Exhaustion starts one serial 30-second polling fallback; after sixty seconds the same job probes SSE again. Stream, retry, and polling work cannot stack, and screen disposal cancels all of them.
- A stream that opens but yields no frame is also failure, not liveness: after thirty seconds the client cancels it, performs one authorized REST catch-up, and only then reconnects. This guards platform engines that establish the request but buffer streaming bytes indefinitely.
F29.AC019 binds token/URL custody, exact refresh behavior, bounded fallback timing, and the guarded two-member Android no-refresh journey. iOS runtime, named-device, native accessibility-service, and store-binary evidence remain explicit gaps.
On 2026-08-02 UTC, one lifecycle-owned Pixel 7 API 35 session passed all eight related Messages
flows in one Maestro process. The incoming second-member message appeared with no user refresh or
navigation; Redis subscriber and persisted-message custody were both exactly 1:1. Built and
installed prodDebug SHA-256 matched at
05e94db41ad441b68beb787bd00aef6c26676127fb1aa5c5f7600af0ceab5aca.
2026-08-02 — Native live updates preserve the reader’s position
Type: Native UX fix / reading position
Summary:
- The channel and direct-message screens previously ran an unconditional
animateScrollToItemwhenever the live observer replaced the message list. A member reading older history therefore lost their viewport as soon as another member wrote. - The shared scroll policy now distinguishes an initial/context load, an at-latest reader, and a scrolled-up reader. At the tail it follows new messages; away from the tail it keeps the list stable and surfaces an accessible, count-bearing New messages action. A current-member send and an explicit Jump to latest still follow the tail by design.
- Historical search context remains pinned; the existing Jump to latest action is the only escape.
Tests:
android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/ui/messages/MessageThreadScrollPolicyTest.ktcovers initial/context loads, at-latest follow, accumulated remote messages, own-send follow, and the latest-edge calculation.- The lifecycle-owned Android Messages Maestro runner extends the passive live-convergence flow with an older-history scroll, a New messages assertion, and the explicit jump action. Named Android/iPhone and native accessibility-service proof remain separate Contract Graph gaps.
2026-08-03 — Native keyboard dismissal preserves the selected thread
- The Android Messages entry owns system Back whenever a channel or direct-message overlay is selected. While the composer IME is visible, it hides the keyboard and clears focus itself before Navigation3 can see the event; when an IME or automation driver delivers a second callback after the inset has become hidden, the entry consumes the bounded 2 second handoff instead of clearing the selected channel or direct-message thread. A second explicit Back within the 500 ms grace window remains navigation, including while the IME is still settling; flow 91 retains the explicit thread → directory Back path once the keyboard is closed.
- The selected channel header exposes a stable
message_thread_channel_<id>identity target, and the live-convergence journey asserts it before and after keyboard dismissal before scrolling into older history. The existing explicit thread Back path still returns to the Messages directory when the IME is not involved. - Focused Android source/policy tests and one guarded Pixel 7 API 35 lifecycle passed all nine
related Messages flows in one Maestro process against byte-identical built/installed prodDebug
APK SHA-256
34f16e51aabe351d9cc357144bd29b73e27554993ad95e3b44a15c6b9a299498. This includes the explicit thread → directory → original Lumi top-level shell Back regression; the runner’s bounded Pixel Launcher restart and software-GPU selection are host recovery controls, not app behavior. Named Android/iPhone, iOS, native accessibility-service, provider push/tap, live-S3, and production non-recurrence proof remain explicit gaps.
2026-08-02 — Native thread reads advance authoritative unread watermarks
- Android and iOS shared channel and direct-message threads now mark the newest successfully loaded
message as read through the canonical
/api/messages/read/endpoint. A page that completes after the user has switched threads cannot mutate the stale container. - The client sends exactly one of
channelorconversationplus the explicit visible message id, suppresses pending/already-acknowledged requests, queues only a newer target, and refreshes unread directory truth only after server acknowledgement. - Failure never replaces readable messages. The active thread shows localized feedback with an exact Retry action that preserves the failed target.
- The backend now verifies that an explicit message belongs to the selected live channel or conversation before advancing its watermark, closing a same-tenant cross-container integrity gap.
Tests were specified first as F29-READ-01..04. F29.AC018 binds backend exact-scope tests, shared wire and concurrency/state tests, bilingual UI source proof, and lifecycle-owned Android database custody. Named-device and iOS runtime proof remain explicit gaps.
2026-08-02 — Native Messages exposes explicit signal capture without action clutter
Type: Native Messages trust-boundary completion and UX refinement
Shipped behavior:
- Channel and direct-message bubbles now rest with one localized 48 dp overflow affordance instead of exposing Reply, Edit, and Delete as a permanent toolbar beneath every message. The inline tray brings itself above the composer and retains the existing authorization gates.
- Flag for Lumi sends an exact
extract=falserequest. Pending and already-captured messages cannot duplicate the client request; success changes only the current message’s flag state so a reaction or edit that arrives concurrently is not overwritten, while failure remains retryable. - Captured state is server-owned through
is_flagged; it survives reload and is represented by a distinct localized, disabled action rather than optimistic success copy.
Tests were specified first as F29-SIGNAL-01..04. F29.AC017 binds the backend evidence/audit
boundary, exact KMP wire request, state and concurrency proofs, shared source/accessibility checks,
and a lifecycle-owned Android journey with database custody. The guarded Pixel 7 API 35 session
passed all six related flows in one Maestro process against byte-identical built/installed prodDebug
SHA-256 bcc6cd68988a2f621ec8ddf78a06ee6af2e414092b2392008436d536a0023ca7; exact signal custody was
1:1:True:ceo@demo.local. Named Android/iOS device, native accessibility-service, and store-binary
evidence remain explicitly open.
2026-08-02 — Native replies retain their message context
Type: Native Messages completion and API-boundary hardening
Shipped behavior:
- Every live channel and direct-message bubble exposes a localized 48 dp Reply action. Selecting it places the sender and parent excerpt directly above the composer, where it can be cancelled without losing the draft.
- Successful sends clear reply state; retryable failures preserve the exact target and draft; changing channel, topic, or direct conversation clears stale reply ownership.
- The backend rejects foreign-tenant, deleted, cross-channel, cross-topic, and cross-conversation parents. All message response paths batch the parent preview so native reconciliation and search do not silently erase the quote or introduce an N+1 query.
Tests were specified first in docs/test-plan/f29-messages-completeness.md as F29-REPLY-01..04.
Backend integration and shared state/wire/source proof are green. The final lifecycle-owned Pixel 7
API 35 emulator session passed all five Messages flows in one Maestro process; exact reply readback
was Launch:True:26080277 exact quarterly decision:ceo@demo.local, and built/installed prodDebug
SHA-256 matched at 74408cfa140a3ad0716807b89b5a3cb47cc770c623cf90cf95e2e46864631885.
Named Android/iOS device, native accessibility, and store-binary evidence remain explicitly open.
2026-08-02 — Exact global-search result continuity
Type: Native Messages UX and authorization hardening
Shipped behavior:
- Selecting a message in workspace-wide Search opens the exact historical message in its current channel/topic or direct conversation on Android and iOS, with the same highlight and explicit return-to-latest behavior as in-screen Messages search.
- The generic F80 descriptor remains
screen=messages, id=<message UUID>. Native Messages resolves that UUID at navigation time through a tenant- and visibility-scoped F29 endpoint so stale channel names, topics, membership, or conversation metadata are never trusted from search results. - A foreign-tenant, private-channel, removed-member, or non-participant lookup returns not-found and exposes no parent identity. The native surface stays recoverable and must not crash or silently pretend the directory is the requested result.
Tests were specified first in docs/test-plan/f29-messages-completeness.md as F29-SEARCH-05/06.
Backend, shared JVM, Android route, iOS route, and the lifecycle-owned Android global-search proof
are green. The final four-flow run used installed prodDebug SHA-256
6d5d027de5df8db27a8df21a250e3e70ff41157f1b8d86fb99cbc125e79843da; named-device, iOS
runtime, live-data, and store-binary evidence remain open.
2026-08-01 — iOS attachment taps reach the authorized system opener
Type: iOS KMP bridge repair
Summary: The shared thread already reauthorized every attachment tap through the message download endpoint, but the iOS Messages factories omitted the callback that consumes the returned short-lived URL. Both the directory-hosted and pushed thread controllers now forward that callback to a native opener that accepts only HTTP(S). Source contract proof and two focused XCTests passed against a freshly rebuilt XCFramework on the approved simulator. Named-device, end-to-end reopen/download, live-S3, native accessibility, and store-binary evidence remain open.
2026-08-01 — Direct-message attachments receive native runtime custody proof
Type: Runtime-proof completeness
Summary: The lifecycle-owned Android attachment runner now creates a participant-scoped direct message alongside its channel fixture and executes both attachment journeys through one measured APK and one Maestro process. The DM flow selects the real document through DocumentsUI, sends an attachment-only message, renders its filename and byte size, and finishes with database readback that binds the completed private upload to the exact conversation and sender. Emulator proof does not close named-device, iOS, authorized reopen/download, live-S3, or store-binary gaps.
2026-08-01 — Linked channel roster is visibly inherited on native
Type: Runtime-proof and accessibility refinement
Summary: The shared member directory now exposes a stable semantic selector for its localized read-only explanation. The lifecycle-owned Android roster runner creates both an ordinary private channel and a team-linked channel, then runs both flows through one measured APK and one Maestro process. The ordinary flow proves list/add/confirmed-remove with retained inactive-row custody; the linked flow proves inherited members remain readable while Add and Remove controls are absent, with database readback confirming the parent-owned roster remains exactly two active rows. Emulator proof does not close named-device, iOS, or native accessibility-service gaps.
2026-08-01 — Native channel and DM reactions use server truth
Type: Android/iOS KMP parity and daily-use refinement
Summary:
- Channel and direct-message bubbles now render aggregated reaction counts and current-member state.
- A compact inline six-emoji picker matches the backend allow-list exactly, auto-scrolls its options into view without expanding beneath the fixed composer, and is hidden for tombstones.
- Reaction toggles are serialized per message, reconcile the returned message, and preserve visible retry feedback without optimistic-count drift.
Verification: shared repository, screen-model success/failure/concurrency, and source-parity tests;
the lifecycle-owned Pixel 7 API 35 Android journey built and installed prodDebug APK SHA-256
b43b38d3a19d4a42d104d555ec30af69d45740a675498734d7ff3dea51353609, selected and sent a
private 23-byte file, toggled 👍 through the inline picker, rendered the reconciled count pill, and
passed exact attachment plus one-row reaction database custody.
2026-08-01 — Native channels expose the topic digest
Type: Android/iOS KMP parity and UX refinement
Summary:
- Opening a native channel now loads its server-owned
ChannelTopicdigest and presents a compact, horizontally scrollable topic rail beneath the thread header. - Unread topic counts remain visible in the rail; selecting a topic reloads only that bounded channel thread and sends subsequent messages with the selected topic.
- The existing
generalfallback remains available for a channel whose digest is empty, so topic fetch failure does not block reading or sending.
Verification: shared screen-model test covers digest loading, unread context, selection, and the
topic passed to the message request. The lifecycle-owned Pixel 7 API 35 journey rendered the rail,
selected Launch, and persisted the attachment-only message under that exact topic in the same
artifact-bound run. Named Android/iOS device proof remains open.
2026-08-01 — Private message attachments accept safe business files
Type: Bug fix / native attachment completion
Summary:
- Private presigning now validates by exact purpose.
attendance_photoremains image-only and capped at 5 MB;message_attachmentuses the safe MIME allow-list and its own 25 MB cap. - Android reads document-provider streams through the same 25 MB client boundary and surfaces picker/read failures in the thread instead of buffering an unbounded file or failing silently.
- Previously, the API view validated message files correctly and then the shared presign service revalidated every private object as an attendance photo, returning 500 for valid TXT/PDF files.
- The lifecycle-owned Pixel 7 API 35 proof now selects a real 23-byte TXT through DocumentsUI,
uploads and completes it against the private-object shim, sends an attachment-only channel
message, renders filename/size, and verifies authoritative consumption custody. Installed APK
SHA-256:
e59a837e11aaf085e94d1e31398aecbfff02c5ef091926524a9fe0799491903f. - Named Android/iOS, DM reopen/download, live-S3, native accessibility, and store-binary proof stay explicit gaps.
Verification: focused attachment pytest 6/6; runtime policy unittest; guarded lifecycle runner.
2026-07-30 — Specify inherited team/project channels
Type: Behavior Change
Summary:
- A channel can belong to one team, one project, or neither.
- Linked membership materializes the parent roster and cannot be edited directly.
- The first linked channel is protected; additional parent-scoped channels remain supported.
- Team/project detail routes to the canonical F29 mobile thread.
Reason:
- Two independently editable rosters would eventually leak team or project discussion after a member is removed.
Impact:
- Existing unlinked public/private channels retain current behavior. ADR-050 governs linked channels only.
Tests:
- Planned in
docs/test-plan/project-team-channels.mdcases C01–C18, W04–W05, and M07/M15/M20.
2026-07-25 — Mobile threads render the newest message beside the composer
Type: Mobile bug fix
Summary:
- Channel and direct-message APIs retain their keyset-friendly newest-first transport order.
- Shared mobile presentation reverses each loaded page once at the screen-model boundary so channel and direct-message threads render chronologically from top to bottom.
- Sparse threads remain top-aligned, while the newest loaded message is the final list item and the existing scroll-to-last behavior places it beside the bottom composer.
- Current-member channel and direct-message bubbles align to the trailing edge, so a newly sent message occupies the expected bottom-right reading position.
Regression:
- Shared model tests feed the real newest-first transport shape and assert oldest-to-newest state for both channel and direct-message threads.
- A shared source contract guards sender-aware trailing alignment in both thread implementations.
- Named Android and iOS runtime confirmation remains required before closing the visual proof gap.
2026-07-24 — Mobile message threads use compact trailing actions
Type: Android/iOS KMP mobile layout repair
Summary:
- Channel and direct-message headers now use Lumi Chat’s auto-mirrored 48dp Back icon with localized accessibility semantics instead of a visible text label.
- Permitted Edit and Delete message actions are now localized 48dp icons aligned to the right, preserving message width while retaining the existing edit dialog, delete confirmation, and sender authorization rules.
MessageThreadCompactActionsSourceTestprotects the compact, accessible layout on both thread variants.
2026-07-24 — Mobile channel rows use swipe-revealed admin actions
Type: Android/iOS KMP mobile layout and interaction repair
Summary:
- Channel rows previously placed full Edit, Archive, and Delete text buttons inside the trailing metadata column. On a compact iPhone this consumed nearly all horizontal space and forced channel names into one-character lines.
- The shared row now retains the full viewport width at rest. Owner, admin, and manager users swipe left to reveal compact, labeled Edit, Archive, and Delete icons; ordinary members receive no management actions. Existing confirmation and server authorization boundaries remain unchanged.
- The two visually identical stacked plus buttons are replaced by one resting Create control that expands into labeled New message and New channel actions. Their distinct callbacks, semantics, stable identifiers, and post-create directory invalidation remain intact.
- Common-source compilation and deterministic source/model contracts cover the implementation. Named iOS and Android device gesture and visual proof remain explicit gaps.
2026-07-20 — iOS keeps New message and New channel as separate actions
Type: iOS navigation and accessibility repair
Summary:
- The shared Messages directory can expose New message and New channel at the same time. Both
actions previously inherited the generic
create_fabidentifier and generic spoken label, so automation and assistive navigation could not address either intent explicitly. - The iOS bridge also forwarded both actions through one
onCreatedcallback, while the native shell mapped that callback only to Create Channel. Tapping New message therefore opened the New Channel form even though KMP and Android already modeled separate intents. - Both actions now expose localized, intent-specific semantics and stable identifiers. The iOS bridge preserves the conversation callback end to end, and the native lane stack owns an explicit Create Conversation route. Successful channel or conversation creation emits a payload-free collection invalidation so the independently hosted iOS Messages directory reloads through its own tenant-scoped repository. The directory also resolves the current member and teammate directory on entry, so refreshed DM rows use the teammate’s name instead of the generic fallback. Source/model contracts and FRM-16 guard against callback collapse, selector-order dependence, stale post-create directories, and anonymous refreshed rows; named-device proof remains open.
- Guarded iOS simulator proof passed on build 151 SHA-256
3babcf86822621e3297fc4e6f68b88ce44620ffb0ba458809705cbe46b9b623c. The retained member-picker and DM-thread frames prove the explicit destination, seeded admin label, reachable named row, and editable composer. Because the CEO/Admin DM is idempotent, that runtime proof does not establish a before/after directory delta; source and model proofs own the invalidation claim. Named-device proof remains separate.
2026-07-18 — Android system Back preserves Messages drilldown order
Type: Android mobile navigation repair
Summary:
- A channel or DM thread is a local overlay inside the shared
MessagesScreen, while the Messages directory itself is one Navigation3 route. Android system Back previously popped that outer route and skipped the directory even though the visible header Back action correctly cleared selection. - The Android Messages route now observes channel/conversation selection and consumes system Back to clear that overlay first. The next Back retains its ordinary Navigation3 behavior and returns from Messages to Workspace.
- Focused source and Maestro regressions protect thread → directory → Workspace order. The emulator result is UI evidence only; named Android floor-device proof remains open.
2026-07-16 — Mobile: channel-edit actions stay above the keyboard (COMM-07)
Type: Android/iOS KMP mobile UX repair
Summary:
- Moved channel-edit overlay ownership from each lazy-list row to
MessagesScreen, after the list and FAB layers, so underlying actions cannot paint over the modal. - Made the populated field region independently scrollable and kept Cancel/Save in a reserved action row above the IME.
- Added stable editor selectors, a shared source invariant, and a maintained Maestro regression that opens the populated editor without mutating the demo fixture.
Proof boundary:
- The post-rebase Android dev APK and installed APK matched SHA-256
ad86fab6f17ef186f81bc3a8c427031ff4b22c221a5c9d03cc389b99210c472a;.maestro/critical/58_messages_edit_keyboard.yamlpassed on Pixel 7 API 35 withmInputShown=true. - This emulator result is UI evidence only. COMM-07 remains PM-owned until the named Android floor device and iPhone 12 populated keyboard walks pass.
2026-07-14 — Mobile: harden native DM verification targets (FRM-16 / COMM-08)
Type: Mobile CRUD / verification hardening
Summary:
- Added stable screen, search, member, composer, and send targets to the shared new-conversation and DM-thread screens.
- Added source guards for IME ownership, member selection, and message send wiring; existing sender edit/delete behavior remains covered by the shared thread.
Scope boundary:
- Source hardening only. FRM-16/COMM-08 populated member-picker, conversation creation, thread, and named-device keyboard/send evidence remain PM-owned mobile-audit work.
Tests:
git diff --checkPYENV_VERSION=system python3 scripts/check_docs_truth.pybash scripts/check-platform-parity.sh
2026-07-14 — Contract Graph ownership and proof alignment
Type: Documentation and proof-traceability change
Summary:
- Added the canonical
contracts/features/F29.cuecontract for the 26 messaging API surfaces, channel/topic and DM boundaries, lifecycle/read-state behavior, search/signal capture, and SSE. - Attached stable proof IDs to existing messaging tests covering tenant isolation, roles, keyset pagination, reactions, watermarks, evidence copying, and EventSource negotiation.
- Added the current implementation audit and linked the contract from the feature registry.
- Kept
/hub/messagesbrowser/native runtime and live SSE delivery blocked until target environments produce commit-bound results.
2026-07-13 — Reply threading restored to the partition contract
reply_to keeps the same nullable UUID API field but no longer creates a self-FK into
the future monthly message partitions. The POST path still resolves the referenced row
inside the current tenant and room before storing its id. ADR-008 rule 2 is true again;
new invariants prevent another inbound relationship from silently reappearing.
2026-07-12 — Shared mobile message unsend keeps the history honest
Type: Mobile UX completion (Android + iOS KMP)
Summary:
- An author can delete only their own non-deleted channel or DM message. The mobile surface keeps long-press as an accelerator and adds the required visible, localized Delete message button, followed by one destructive-action confirmation. The server remains the authorization boundary.
POST /api/messages/<id>/delete/returns the soft-deleted message. Mobile now replaces that exact in-memory row with the returned tombstone instead of reloading a list that intentionally omits deleted messages, so every participant sees the muted Message deleted history marker rather than stale text or a disappearing gap.- A failed delete shows only localized plain-language copy; backend details and exception text are not exposed. English, Vietnamese, Malay, Indonesian, Arabic, Filipino, and Latvian values are present.
Tests:
android/shared/.../MessagesRepositoryTest.kt::deleteMessage posts to the correct endpointverifies the real returned tombstone contract.android/shared/.../MessagesScreenModelTest.ktverifies sender/deleted gating and channel/DM tombstone replacement.cd android && ./gradlew --max-workers=2 :shared:jvmTest --rerun-taskscd android && bash scripts/check-platform-parity.sh
2026-07-14 — Shared mobile message edit and DM delete complete sender CRUD
Type: Mobile UX completion (Android + iOS KMP)
Summary:
- Channel and DM threads now expose a localized sender-only Edit message action. The client sends
POST /api/messages/<id>/edit/; the server enforces ownership, non-deleted state, and the fifteen-minute edit window. - DM threads now expose the same visible sender-only Delete message action and confirmation as channel threads. Returned tombstones replace the local row, keeping deleted history honest.
- Edit failures remain retryable and show authored copy; the edit draft remains in the dialog while the request is pending or fails.
Tests:
android/shared/.../MessagesRepositoryTest.kt::editMessage posts body to the correct endpointpins the native API contract.android/shared/.../MessagesScreenModelTest.ktverifies successful local replacement, failure preservation, and sender-scoped CRUD state.cd android && ./gradlew :shared:jvmTest --no-daemoncd android && bash scripts/check-platform-parity.sh
2026-07-14 — Shared mobile manager channel edit completes channel CRUD
Type: Mobile UX completion (Android + iOS KMP)
Summary:
- Manager/admin/owner members can now edit channel name and description from the native channel list. The client sends
PATCH /api/messages/channels/<id>/; server-side manager authorization remains authoritative. - Successful updates replace both the channel-list row and selected channel; failures keep the dialog open with authored retryable copy and preserve the draft.
- All native channel and DM list/create/read/send/update operations now use the live
/api/messages/...router; the previous/api/messaging/...paths were stale and are covered by repository endpoint tests.
Tests:
android/shared/.../MessagesRepositoryTest.ktpins channel create/update and DM create paths.android/shared/.../MessagesScreenModelTest.ktverifies channel replacement, selected-channel synchronization, and failure preservation.cd android && ./gradlew :shared:jvmTest --no-daemoncd android && bash scripts/check-platform-parity.sh
2026-07-14 — Reversible channel archive completes the remaining channel lifecycle
Type: Backend API + Android/iOS KMP mobile CRUD completion (F29 Messages)
Summary:
- Manager/admin/owner members can archive a non-default channel from the native channel list. The shared client calls
POST /api/messages/channels/<id>/archive/, removes the channel from active state only after success, and provides localized confirmation, retry, and failure-preservation behavior. - The backend adds tenant-scoped manager-only
archiveandunarchiveactions. Archived channels remain hidden from normal active reads, and the seeded default channel is protected from archive; destructiveDELETEremains available as a separate irreversible operation.
Tests:
backend/tests/notifications/test_messaging_channels.py::TestChannel.test_manager_can_archive_and_unarchive_channelcovers the API lifecycle, active-list hiding, default-channel protection, role denial, and cross-tenant denial.android/shared/.../MessagesRepositoryTest.ktpins the archive/unarchive endpoints;MessagesScreenModelTest.ktcovers success removal, selection clearing, failure preservation, and manager/default gating.cd android && ./gradlew :shared:jvmTest --no-daemoncd android && bash scripts/check-platform-parity.sh
2026-07-14 — Native archived-channel recovery completes the reversible lifecycle
Type: Backend API + Android/iOS KMP mobile CRUD completion (F29 Messages)
Summary:
- Managers can list tenant-visible archived channels through
GET /api/messages/channels/archived/; members remain denied and cross-tenant rows remain excluded. - The shared native Messages screen now renders an Archived channels section with a localized Restore action, retryable restore failure state, and immediate active-list reconciliation after success.
Tests:
backend/tests/notifications/test_messaging_channels.py::TestChannel.test_manager_can_archive_and_unarchive_channel,test_member_cannot_list_archived_channels, andtest_archived_channel_list_is_tenant_scopedcover listing, role denial, and tenant isolation.android/shared/.../MessagesRepositoryTest.ktpins the archived-list endpoint;MessagesScreenModelTest.ktcovers restore success and failure preservation.cd android && ./gradlew :shared:jvmTest --no-daemoncd android && bash scripts/check-platform-parity.sh
2026-07-09 — SSE stream never worked in a real browser (406 + token 401)
Type: Backend bug fix (prod incident — see postmortem docs/postmortems/2026-07-09-chat-sse-stream-never-worked.md)
Summary:
GET /api/messages/events/406’d every request carryingAccept: text/event-stream— the header every browserEventSourcesends — because DRF content negotiation had no renderer for that media type. AddedEventStreamRenderer(pass-through,media_type="text/event-stream") onMessagesEventStreamView.resolve_sse_tokenused RedisGETDEL, which Redis < 6.2 rejects as an unknown command; the bareexceptturned that into a silent 401 for every token on prod. Now falls back to a pipelinedGET+DELonResponseError, and both token helpers log withexc_info=Trueinstead of swallowing the cause.
Tests:
tests/notifications/test_messaging_events.py—test_events_endpoint_accepts_browser_eventsource_accept_header,test_events_endpoint_401_keeps_event_stream_accept_header,test_resolve_sse_token_falls_back_when_getdel_unsupported,test_resolve_sse_token_getdel_fallback_missing_key.
2026-07-06 — Full-bleed messages workspace + top-aligned thread flow
Type: Frontend UX polish
Summary:
/hub/messagesnow opts out of the global centeredmax-widthshell and renders as a full-bleed workspace surface, so the channel rail, topic rail, and conversation pane use the available canvas like a serious chat client instead of a centered document page.- The message thread now flows top-to-bottom for sparse conversations instead of pinning the entire exchange to the bottom edge with a large empty zone above it.
- Mobile keeps the same two-level drilldown, but the full-width shell and top-aligned thread remove the squeezed, sticky-feeling layout that made the surface harder to use.
Tests:
cd frontend && pnpm test src/test/_shared/Layout.test.tsx src/test/connect/MessagesPage.test.tsx
2026-08-05 — Manager-gate native channel creation
Type: Fix (shared Android/iOS UI authorization presentation)
Summary:
- The shared Messages create menu exposed New channel to ordinary members even though the
POST /api/messages/channels/endpoint is manager-gated. Members could open the form and only receive a generic failure banner. - The native create action now fails closed until the authenticated role resolves to Owner, Admin, or Manager. Backend authorization remains the source of truth, and the authorized create path is unchanged.
Evidence:
- Sentry
LUMI-ANDROID-2Nshowed a channel-directory connect timeout, not a distinct create-POST signature; this slice therefore stays limited to the proven UI/API permission mismatch. - Shared JVM role proof:
MessagesScreenModelRoleTest.channelCreationIsManagerGated.
2026-07-02 — Fix §11 stale line-number references
Type: Documentation
Summary:
- §2 feature checklist:
flag_message_as_signalcall site corrected from views.py line 298 to line 330 (MessageFlagSignalView). - §2 feature checklist:
GROUP_DM_MAX_PARTICIPANTScitation updated — constant is defined at services.py line 16 (not at lines 63-64, which are the enforcement block). - §11 Row 5 evidence: line reference 298 → 330.
- §11 Row 8 evidence:
line 63-64→defined at services.py line 16; enforced at lines 63-64. - No code changes.
2026-07-01 — Close F29 topic-length proof gap
Type: Backend test coverage + documentation truth fix
Summary:
- Closed the stale F29 audit row that still treated topic-length validation as unconfirmed.
- The channel-message endpoint already returned
400for topic names longer than 60 characters; this slice adds the explicit regression test and updates the audit row to match.
Tests:
cd backend && DJANGO_SETTINGS_MODULE=config.settings.test ./.venv/bin/python -m pytest tests/test_messaging.py -k "test_post_to_channel_rejects_topic_longer_than_60_chars or test_post_to_channel_with_topic or test_post_to_channel_requires_topic" -x --tb=short
2026-07-01 — Correct F29 DM flag-as-signal audit row
Type: Documentation truth fix
Summary:
- Closed the stale F29 row that still treated DM flag-as-signal behavior as unspecified.
- The shipped messaging stack already allows DM participants to flag a message as signal, creates
EvidenceSource(chat_note), and returns flagged state back through the DM message list.
Tests:
- No new code path. Evidence already exists in
backend/tests/test_messaging.py(test_flag_dm_message_creates_evidence,test_conversation_messages_get_reflects_flag_state).
2026-06-21 — Unread counter never cleared after reading (your own messages counted as unread)
Type: Fix (backend — read-watermark)
Summary: The dock/nav Messages badge ticked up when you sent a message and never cleared. Root cause: post_message (backend/apps/messaging/services.py) advanced the channel/topic digest but not the sender’s own read watermark, so the author’s own message satisfied id > last_read_message_id and counted as unread to themselves. Fix: after creating a message, advance the sender’s ChannelMember/ConversationMember watermark to the new message id via advance_watermark (monotonic; recipients untouched). Frontend: MessageThread now invalidates the useUnreadCounts channel/conversation list queries on mark-read so the badge clears immediately instead of waiting for the 60s ambient poll.
Tests: backend/tests/test_messaging.py::TestSenderOwnMessagesAreRead (own channel + DM message not unread to sender, other members still see unread, watermark monotonic). Corrected three pre-existing tests that had encoded the buggy behavior by posting as the requesting member. Frontend MessageThread.test.tsx wrapped in a QueryClientProvider.
Postmortem: docs/postmortems/2026-06-21-unread-counter-own-messages.md.
2026-06-21 — Floating Messages launcher (Command Dock)
Type: Feature (UI/UX)
Summary:
- Messages now surfaces in a floating, bottom-end Command Dock pod (
frontend/src/components/dock/MessagesPanel.tsx) on every authenticated screen — a fast, unread-first launcher of channels + direct conversations that deep-links into/hub/messages. - Live unread counts (per-channel
unread_count+ per-conversationunread_count) are aggregated byfrontend/src/hooks/queries/useUnreadCounts.ts(60s ambient polling) and shown as a dot badge on the pod and an inline badge on each row, plus an inline badge on the sidebar Messages nav item. - Tests:
frontend/src/test/CommandDock.test.tsx.
2026-06-21 — Channel edit + delete affordances
Type: Feature (CRUD completion)
Summary: ChannelViewSet (ModelViewSet) supported PATCH/DELETE but the UI only created/joined/left channels. Added channelsApi.update/delete, useUpdateChannel/useDeleteChannel, and a manager/admin/owner-gated channel header in MessagesPage.tsx with an Edit dialog (name + description) and a confirmation Delete. Conversations remain backend POST-only by design (no edit/delete — intentional). i18n keys added to en/vi (then propagated to all locales).
2026-07-06 — Messages material split workspace polish
Type: Frontend UX polish
Summary:
/hub/messagesnow renders as a full-bleed, full-height material split workspace instead of a centered content page.- The channel rail, topic rail, conversation shell, persistent thread header, message states, and composer use Lumi semantic tokens, rounded material sheets, and calm high-density spacing.
- The composer is a bottom dock/recessed writing well, preserving the left/top mental model and avoiding a floating mid-screen form on desktop and mobile.
- Message thread loading, empty, date-separator, row, action, and reaction states now use translated labels and tokenized material states.
- Locale key parity was repaired for the
messagesnamespace, plus pre-existingcompanies,hub, andtodaymissing-key drift, using English fallback text where a locale lacked a human translation.
Verification:
cd frontend && pnpm test src/test/_shared/Layout.test.tsx src/test/connect/MessagesPage.test.tsxcd frontend && pnpm test src/test/_shared/i18n.test.tscd frontend && pnpm buildnode frontend/scripts/check-design-tokens.mjsgit diff --check
2026-06-16 — CEO-undeniable frontend pass (UX)
Type: Feature (frontend only — no backend/API/data-model change; all endpoints pre-existed)
Audit doc: docs/ui-ux/F29-chat-ceo-undeniable.md — top-10 ranked by undeniability for a CEO. Items 1–6 shipped this pass:
-
Search UI — wired the existing
/api/messages/search/endpoint to an in-app search box with jump-to-thread (selects channel + topic). Previously the headline AC (“đã chốt giá bao nhiêu?”) had an endpoint but no UI. -
Unread that doesn’t lie —
MessageThreadnow callsmarkReadwith the newest loaded message id on open and notifies the page to refresh badges (guarded by a per-thread ref). Fixes badges that never cleared. -
Optimistic send —
MessageThreadexposesaddOptimistic/reconcile/failOptimisticvia ref; sends echo instantly and reconcile on server ack (error_sendtoast + rollback on failure). -
@mention autocomplete — composer member-picker dropdown (Up/Down/Enter/Esc) inserting
@Name. -
Design-token compliance — all five
components/messaging/*files +MessagesPage.tsxmigrated off rawvar(--color-lumi-*)/bg-whiteto semantic Lumi tokens (Rule 4);check-design-tokens.mjsclean. -
Date separators + sender grouping in the thread (Today/Yesterday/date; consecutive same-sender within 5 min collapse the header).
-
Jump-to-message highlight —
MessageThreadacceptshighlightMessageId; clicking a search result scrolls to and flashes the message (ring-2 ring-primary, fades ~1.8s). -
Flag-as-signal affordance — after flagging, the message shows a “captured as signal” chip and the menu action relabels/disables (session-scoped; persistent badge needs a backend
flaggedfield — the one open item for this surface). -
Topic digest — topic pane orders unread-first, shows an unread-topic count header and per-row relative last-activity time.
-
Composer polish — Enter & Cmd/Ctrl+Enter send (Shift+Enter newline), keyboard hint line, disabled-state copy, mobile safe-area padding.
Verification (full pass): tsc --noEmit 0 errors · design-token guard clean for messaging/* + MessagesPage · MessagesPage/MessageThread/i18n tests green. Also fixed a stale, pre-existing i18n.test.ts assertion (registered-namespace count 49→50 — the notifications namespace was registered in config but missing from the test’s expected list; unrelated to chat).
Backend — persistent flag state (closes item 8). MessageSerializer gained a read-only is_flagged boolean. It is computed per request, not stored: each message-list/search/single response populates context["flagged_ids"] from messaging.services.flagged_message_ids(organization_id, ids), a single tenant-scoped query — EvidenceSource.objects.filter(organization_id=…, source_type="chat_note", metadata_json__message_id__in=ids). No FK into Message is introduced (preserves §4.1 rule 2; flag state lives in the copied EvidenceSource, queried by metadata_json__message_id). The frontend seeds the “captured as signal” badge from is_flagged so it survives reloads. Tests: tests/test_messaging.py::TestIsFlaggedField (6, incl. test_flag_state_does_not_leak_across_tenants — cross-tenant severity). §5 API: the Message payload now includes is_flagged: bool.
Tests: MessagesPage.test.tsx + MessageThread.test.tsx 18/18 green (added markRead to the MessageThread test mock; hardened the markRead call to never crash the thread). tsc --noEmit 0 errors. Added 4 new messages namespace keys (date_today, date_yesterday, mention_label, search_close) to all 56 locales for key parity.
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /messages -> /hub/messages (app-wide IA restructure; API paths unchanged).
2026-06-14 — Strategy v2 realignment (positioning)
Type: Documentation Change
Summary:
- §1 reframed from “minimal but good enough to replace Teams/Slack” to opt-in signal capture that sits alongside the owner’s existing Zalo — not a Slack/Teams replacement, not a chat-migration mandate. Competitor restated as Zalo (free incumbent), not US SaaS.
- §12 Images open question reworded: “Slack replacement” → “serious place to bring Zalo work” framing.
Reason:
- Align with competitive-strategy-v2.md §9.1 / §7.2 (forcing chat migration is the wrong fight; accept the mess, capture signal) and the registry Positioning doctrine (strategy v2, 2026-06-14) rule 1 (competitor = Zalo + Excel + owner memory, not US SaaS).
Impact:
- Positioning prose only. No behavior, data-model, API, test, or status change — F29’s shipped scope (channels/topics/DMs/flag-as-signal) already matches “opt-in signal capture”; no audit gap added.
Tests:
- None (spec-only).
Notes:
- The trust line (§1: AI does not ambiently read chat; signal only on explicit human flag) already encodes the opt-in stance — this edit makes the positioning prose consistent with it.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8; §8 Diagrams → §10; §9 Implementation Audit → §11 (10-row table with Zulip reference study note); §10 Open Questions → §12; §11 Reference Study absorbed into §11 note; §13 Edge Cases absorbed into §11 note; §14 Error States → §9; §15 Bug Log → §13.
- §11 audit: 10 rows, 8 ✅, 2 gaps (topic length 400 response, DM flag-signal).
2026-06-12 — Implement edit marker, delete tombstone, emoji validation (fleet C, round 23)
Type: Feature
Summary:
- Added
is_deletedBooleanField to Message model + migration 0003. delete_messageservice now setsbody_md = "tin nhắn đã xóa"andis_deleted = True.- MessageSerializer emits “(đã sửa)” marker in
body_mdwhenedited_atis set. MessageReactViewvalidates emoji againstSTANDARD_EMOJI = {"👍", "❤️", "😂", "😮", "😢", "🙏"}— non-standard emoji returns 400.- 7 xfail(strict) tests in test_red_r22_f.py now pass.
2026-06-12 — Added Edge Cases and Error States sections (CMD fleet agent E, round 11)
Type: Documentation Change
Summary:
- Added §13 Edge Cases (10 items) and §14 Error States (10 items).
2026-06-11 — Correct stale implementation-audit status
Type: Documentation Change
Summary:
- Replaced the outdated “Not built” implementation-audit text with the shipped P07 PR-1..PR-5 state.