shipped foundation

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 #chung channel exists per org (seeded). is_private field enforces access via get_queryset; IsOrgManager permission gates create/update/destroy/archive/unarchive and archived-list access; ensure_default_channel() called in create_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). topic field 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.
  • 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 = 8 defined 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 co cannot match unrelated members through a hidden .com domain; 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.
  • As a member, I want @mentions and unread state that doesn’t lie.
    • @name notifies the mentioned member (in-app badge); @channel is manager+ only — PermissionError raised 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_attachment purpose, 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_signal called at messaging/views.py line 330 (MessageFlagSignalView); creates EvidenceSource (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_messages service 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 Tha finds Thả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_to id, 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 ChannelMember row 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.

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, nullable team, nullable project. A database check allows at most one parent. is_default remains exclusive to the organization-wide #chung; linked channels use a separate is_parent_default flag. Both parent FKs use on_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_id watermark (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 via ConversationMember (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), nullable reply_to UUID soft reference validated in the write service, edited_at, deleted_at, and is_deleted.
  • Reaction — message FK, denormalized message_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 UserMessage rows (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:

  1. 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 treating id as the pk — the composite is a DB-level concern only.
  2. Nothing long-lived holds an FK into Message except Reaction, which carries message_created_at for 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.
  3. 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 the created_at bound that makes partition pruning actually fire.
  4. Unread math is bounded: counts computed as id > watermark with a server-side display cap (99+) — never an unbounded COUNT(*) over history.
  5. Search defaults to a recent window (90 days; explicit all=true opt-out) so per-partition GIN indexes prune later; search_tsv is a generated column from day one, not an afterthought backfill.
  6. ChannelTopic is 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)

MethodPathMin roleNotes
CRUD/api/messages/channels/manager (create) / memberJoin/leave actions; private ACL server-side
GET/api/messages/channels/archived/managerLists tenant-visible archived channels for restoration
POST/api/messages/channels/<id>/{archive,unarchive}/managerReversible lifecycle actions; default channel cannot be archived; active reads omit archived channels
GET/POST/api/messages/channels/<id>/members/member* / managerActive roster; POST adds an active org member to an ordinary channel
DELETE/api/messages/channels/<id>/members/<member-id>/managerRemoves 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/memberChannel 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=participantsDMs; 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 / participantFresh short-lived private download URL; visibility is revalidated for every request
POST/api/messages/<id>/react/ …/edit/ …/delete/ …/flag-signal/member / sender / sender / memberEdit window enforced server-side
POST/api/messages/read/memberAdvance watermark to a message id (channel or conversation); watermark only moves forward
GET/api/messages/search/?q=&channel=&sender=&all=memberVisibility-scoped; defaults to last 90 days, all=true for full history (§4.1)
GET/api/messages/events/memberSSE 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/messages is a full-bleed workspace surface, not a centered document page: it bypasses the app shell’s max-width wrapper, 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 MessagesScreen applies imePadding() 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_manage is 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 existing ChannelMember row 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: messages namespace, 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

#CaseTypeWhere
1Channel CRUD, reversible archive/unarchive, join/leave, private ACL (non-member: 404, list-excluded)backendnew classes in backend/tests/
2DM visibility: non-participant blocked; unique 1:1 pairbackendsame — same severity as cross-tenant
3Cross-tenant blocked on every endpointbackendmandatory
4Topic grouping (digest from ChannelTopic, bumped transactionally on send), id-watermark unread math, mark-all-readbackendsame
5Edit window, tombstone delete, reaction uniquenessbackendsame
5bPartition 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=truebackendsame
6flag-signal → EvidenceSource(chat_note) + optional extraction with evidencebackendsame
7Search respects visibilitybackendsame
8SSE stream delivers only my visible conversations’ events — never another member’s DMs/private channels, never another tenantbackendsame — same severity as cross-tenant
9Channel→topic→send→read flow; DM flow; mention badge; message appears without manual refresh (SSE or fallback)e2enew describe("Messages")
10One-parent constraint, private flags, separate org-default/parent-default invariants, unique parent-default per containerbackendbackend/tests/notifications/test_container_channels.py
11Roster synchronization, removal revocation, and generic join/leave denialbackendbackend/tests/notifications/test_container_channels.py
12Nested list/create authorization and cross-surface direct access return 404backendF03/F04 P60 suites
13Global Messages lists linked rows once with parent labels and routes governance to the parentfrontend/shared + Chromium/nativeP60 C15/W04/M15
14Channel 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 retrybackend + sharedbackend/tests/notifications/test_messaging_channel_members.py; shared screen-model tests
14bLinked-channel member directory remains visibly inherited/read-only on native: roster is readable, explanatory state is announced, and no direct Add/Remove action existsshared + Android runtimeshared source contract plus the lifecycle-owned roster Maestro suite
15Message 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 readbackend + shared + nativeF29 completeness plan and guarded Maestro proof
15bNative 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 custodyshared + Android runtimesame lifecycle-owned private-storage runner and exact database custody readback
15ciOS channel and DM attachment taps request a fresh visibility-authorized download URL and forward only HTTP(S) URLs to the system openershared + iOSexported KMP callback source guard plus focused XCTest
15dWeb attachment picker: private purpose, text-plus-file and attachment-only send, retry-preserved draft, metadata row, authorized download callback, and bounded validationfrontend + backendfrontend/src/test/connect/MessageAttachments.test.tsx plus focused API custody tests; authenticated browser proof remains open
16Native contextual reply: same-topic/channel or same-DM validation, batched parent preview, reply/cancel/retry state, persisted quote rendering, and lifecycle send/readbackbackend + shared + Android runtimeF29 completeness plan and guarded Messages Maestro runner
17Native explicit signal capture: exact non-extracting request, pending/already-captured guard, concurrent-state-safe reconciliation, quiet accessible overflow action, and lifecycle EvidenceSource/AuditLog readbackbackend + shared + Android runtimeF29 completeness plan and guarded Messages Maestro runner
18Native 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 countbackend + shared + Android runtimeF29 completeness plan and guarded Messages Maestro runner
19Native 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 pinnedshared + Android/iOS runtimeshared JVM scroll-policy tests plus the lifecycle-owned Messages Maestro runner
20DM 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 semanticsbackend + shared JVM/source + Android runtimetest_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

#ErrorResponseNotes
ES1POST to private channel as non-member — §5 says GET returns 404 for non-members; POST behavior not explicitly statedGap: 404 vs 403 for non-member POST is unspecifiedNo dedicated test for this case
ES2Edit after 15-minute window — server rejects edit past windowError response (status code, message key) not defined in specEdit window enforced server-side; no test for expired-edit error shape
ES3Tombstone rendering after edit — message edited then deletedGap: whether tombstone shows “(đã xóa)” with or without “(đã sửa)” is unspecified(đã sửa) emitted at serializer level; tombstone takes precedence
ES4SSE stream disconnection + catch-up — prolonged disconnection (>5 min) misses eventsClient reconnects → must fetch missed messages since last-known watermarkCatch-up logic duration and event-loss window not detailed
ES5Search from non-member of private channel — visibility-scoped search (§5, §7)Gap: whether non-member search returns 403 or silently omits resultsNo explicit test for omit-vs-403 behavior
ES6Duplicate 1:1 DM creation — unique pair enforced via normalized participant hash (§4)Gap: 409 or 200+existing for duplicate-1:1 upsert not specifiedunique 1:1 pair enforced per §4 but upsert vs 409 not stated
ES7@name mentions deactivated member — mentions ArrayField stores member UUIDsGap: mention resolution for departed members not describedNo test for deactivated-member mention
ES8Channel name collision (case) — Channel.name unique per org; case normalization unspecifiedGap: case-sensitive or case-insensitive unique constraint not statedVietnamese names may confuse on “Chung” vs “chung”
ES9Mark-all-read with data race — watermark only advances forward; “mark all read” to channel’s latest idGap: mark-all-read race-safety not explicitly testedWatermark must not regress if latest id is somehow behind current
ES10flag-signal on DM message — participant flags a private DM message as signal201 with evidence_id; same visibility rules as DM read accessDMs are flaggable by participants; evidence is still copied as chat_note without an FK into Message
ES11Linked channel direct join/leave400 membership_inheritedChange the parent roster instead
ES12Linked default archive/delete400 default_channel_requiredParent retains one ready conversation
ES13Linked channel with both parents or a foreign parentdatabase/serializer rejectionFail closed before any membership is written
ES14Delete linked parentExplicit confirmation names affected channels; parent deletion cascades linked channels/messages and never leaves an orphan ACL
ES15Create 51st linked channel409 linked_channel_limit; no row or membership change
ES16Linked row in global MessagesParent 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.

#ClaimStatusEvidence
1Channels, topics, DMs; default #chung seeded per org; private ACL via ChannelMember row existence✅ matchP07 PR-1; ensure_default_channel() in create_organization(); private-channel tests
2Topic digest from ChannelTopic table (not GROUP BY over messages); transactional bump on message send✅ matchP07 PR-2; ChannelTopic maintained in same transaction as message write
3SSE push + 30s polling fallback; heartbeat ~25s; per-user event filter (never cross-member/cross-tenant broadcast)✅ matchP07 PR-3; GET /api/messages/events/; §8 test row for SSE isolation
4Keyset pagination: UUIDv7 cursor, before= param, no OFFSET; composite indexes (channel, topic, id DESC)✅ matchP07 PR-4; §4.1 rules enforced from first migration
5flag-signal → EvidenceSource(chat_note) with message text copied (not FK-referenced)✅ matchmessaging/views.py line 330 (MessageFlagSignalView); evidence survives retention by design
6Edit window 15 min server-side; tombstone delete sets is_deleted=True and body_md="tin nhắn đã xóa"; emoji restricted to standard set✅ matchFleet C r23: is_deleted BooleanField + tombstone body + STANDARD_EMOJI validation; 7 xfail tests pass
7SSE 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
8Group DM max 8 participants enforced in messaging/services.py✅ matchGROUP_DM_MAX_PARTICIPANTS = 8 defined at services.py line 16; enforced at lines 63-64
9Topic name max 60 chars: API rejects oversize topic names with 400 before write✅ matchmessaging/views.py explicit length guard; 2026-07-01 test_post_to_channel_rejects_topic_longer_than_60_chars
10flag-signal applies to DM messages visible to the participant✅ matchTestFlagSignal::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_attachment boundary 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ắn directory 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.iosMessagesHostUsesSingleTitleOwner locks 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_unaccent query with one :* prefix term per normalized word, preserving all-term matching while allowing Tha to find Thả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_unaccent configuration 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.searchesOneCharacterQuery and MessagesSearchSourceTest.messageSearchEnablesOneCharacterQueries pass; 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_time previously used Organization.objects.iterator() while each organization performed message, membership, and Telegram work. The dev worker retried this beat task with PostgreSQL InvalidCursorName / cursor ... already exists errors.
  • 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-worker logged 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:

  • MessageReactionSourceTest pins the resting-state/footer contract and shared channel/DM wiring.
  • MessageThreadScrollPolicyTest.ownSendDoesNotReuseAnUnchangedReplyHighlightAsItsScrollTarget pins 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:

  • MessageReactionSourceTest pins 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, and F29.AC025.NATIVE_SAFE_AREA cover 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.sh passed all six flows in one Maestro process on build 185, installed SHA-256 7391f2b40350f21017916ab6d275bc677980c786b7be30509a1966259d59b495, simulator 0A8C774B-C1D3-4A43-816C-81D3D3D849D8; group-DM custody was 1:3:1:1 and 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 as Co therefore missed an active member named Cỏ.
  • 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 co match unrelated .com domains. 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.memberSearchMatchesVietnameseDiacriticsWithoutChangingEmailSearch fails before the corrected member matcher exists and passes with Cỏ/Co, uppercase matching, local-part and explicit full-email matching, plus unrelated .com domain negative cases.
  • CreateConversationSelectionTest.namedMembersExposeTheirEmailAsSecondaryIdentity covers 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 around message 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.channelReplyPreviewLoadsAndHighlightsTheReferencedMessage and directReplyPreviewLoadsAndHighlightsTheReferencedMessage verify channel/DM context loading and exact highlight state. missingReplyPreviewParentLeavesTheCurrentThreadUnchanged verifies the fail-closed boundary.
  • MessagesReplySourceTest verifies 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 NavKey state through its saved-state encoder. The four native creation routes (New channel, contact, conversation, and team) were missing the kotlinx.serialization.Serializable annotation, so opening New message could raise a fatal Serializer for class 'CreateConversationNavKey' is not found exception 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.createRoutesAreSerializableForNavigation3State fails 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, and MessagesLiveUpdatesSourceTest cover 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_at for 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_at for 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.explicitRetryReloadsTheSelectedSearchContext proves 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.failedChannelSendPreservesTheComposerAndExplicitRetryResendsIt and MessagesScreenModelConversationTest.failedDirectSendPreservesTheComposerAndExplicitRetryResendsIt cover both targets.
  • MessagesSendRecoverySourceTest.channelAndDirectThreadsExposeAnExplicitRetryForPreservedSendDrafts protects 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-81D3D3D849D8 passed flows 138, 128, 130, 141, 142, and 143 sequentially in one Maestro process against build 184 SHA-256 9dab5dccd710425548bda01c4ed091308637f6fbf8f563882cbe06d94217c1c0, 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.directoryRowsDistinguishDirectAndGroupConversationIdentity guards the branch, localized resources, and row semantics.
  • F29MessagesRosterRuntimePolicyTest.test_group_dm_flow_proves_localized_group_row_identity_before_open keeps 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:

  • MessageReactionSourceTest protects 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.explicitRetryRestoresLiveUpdatesAfterTheStreamFallsBack asserts that a stale generation cannot erase the retry state.
  • MessageReactionSourceTest and MessagesLiveUpdatesSourceTest protect 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:

  • MessagesScreenModelLiveUpdatesTest covers degraded fallback, explicit retry, duplicate-retry suppression, and recovery after an event.
  • MessagesLiveUpdatesSourceTest covers 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.memberPickerFiltersByNameOrEmailAndUsesDeterministicOrder covers filtering, exclusions, sorting, and no-match behavior.
  • The guarded Android roster flow enters Sales before 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_context test tag and accessible localized label in English and Vietnamese. Direct messages are unchanged because they do not have channel topics.
  • F29-TOPIC-01 and F29.AC002.NATIVE_TOPIC_CONTEXT cover normalization and the native accessibility contract. On 2026-08-03, the guarded iOS lifecycle passed all five roster/DM/notification flows on approved UDID 0A8C774B-C1D3-4A43-816C-81D3D3D849D8 with build 164 SHA-256 a4e6062d811b3efc62c2796d0fd7c3f540d7349de4954e59195f2dddedf27a63; 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-5554 with prodDebug APK SHA-256 61aa3147e45aeda97d1d4d3f5d38622ce7ea09a6f191e9f5da4fba5337acfb9f. 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.ai prodDebug APKs matched at SHA-256 f2bfc75ce6944db52f678b50506a1741a0905164758f7232c6042f62b530c34c; final custody remained group-DM 1:3:1:1, linked roster 2:2, and public participation 0: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-5554 with prodDebug APK SHA-256 b505b3438d343068cc4069848e7f01f7b10d398edd531c0983e3fe35222a0c3f; 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, and MessagesLiveUpdatesSourceTest guard the compact painted surfaces, icon-only semantics, and stable retry selector.
  • MessagesScreenModelLiveUpdatesTest.retryReenablesAfterAQuietTransportFeedbackWindow proves 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 / muted notification-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-81D3D3D849D8 with SHA-256 3121d3e0623eb84b58c596fea620a5dfa60a92537a0abebcbf5726e2a83840ad.
  • 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_view selector because the V2 shell exposed main_tab_view_v2. The shell now restores the shared main_tab_view anchor; 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 attachment Launch:True:1, DM attachment True:1, reply Launch:True:26080277 exact quarterly decision:ceo@demo.local, signal 1:1:True:ceo@demo.local, unread True:True:True, and live 1: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 kill but 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, or unauthorized ADB 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 was 1:3:1:1, linked roster custody was 2:2, and public participation custody was 0: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.channelRowsKeepContextInOneConciseSecondaryLine covers 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 : connected comment 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.connectedFrameConfirmsAReconnectedStreamImmediately protects the wire parser, and MessagesScreenModelLiveUpdatesTest.connectedFrameRecoversWithoutAnUnnecessaryCatchUpRead prevents 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_dm participant 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_flagged state, 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_ONLY protects 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_member truth 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 animateScrollToItem whenever 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.kt covers 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 channel or conversation plus 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=false request. 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 ChannelTopic digest 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 general fallback 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_photo remains image-only and capped at 5 MB; message_attachment uses 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.md cases 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.
  • MessageThreadCompactActionsSourceTest protects 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_fab identifier and generic spoken label, so automation and assistive navigation could not address either intent explicitly.
  • The iOS bridge also forwarded both actions through one onCreated callback, 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.yaml passed on Pixel 7 API 35 with mInputShown=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 --check
  • PYENV_VERSION=system python3 scripts/check_docs_truth.py
  • bash 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.cue contract 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/messages browser/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 endpoint verifies the real returned tombstone contract.
  • android/shared/.../MessagesScreenModelTest.kt verifies sender/deleted gating and channel/DM tombstone replacement.
  • cd android && ./gradlew --max-workers=2 :shared:jvmTest --rerun-tasks
  • cd 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 endpoint pins the native API contract.
  • android/shared/.../MessagesScreenModelTest.kt verifies successful local replacement, failure preservation, and sender-scoped CRUD state.
  • cd android && ./gradlew :shared:jvmTest --no-daemon
  • cd 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.kt pins channel create/update and DM create paths.
  • android/shared/.../MessagesScreenModelTest.kt verifies channel replacement, selected-channel synchronization, and failure preservation.
  • cd android && ./gradlew :shared:jvmTest --no-daemon
  • cd 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 archive and unarchive actions. Archived channels remain hidden from normal active reads, and the seeded default channel is protected from archive; destructive DELETE remains available as a separate irreversible operation.

Tests:

  • backend/tests/notifications/test_messaging_channels.py::TestChannel.test_manager_can_archive_and_unarchive_channel covers the API lifecycle, active-list hiding, default-channel protection, role denial, and cross-tenant denial.
  • android/shared/.../MessagesRepositoryTest.kt pins the archive/unarchive endpoints; MessagesScreenModelTest.kt covers success removal, selection clearing, failure preservation, and manager/default gating.
  • cd android && ./gradlew :shared:jvmTest --no-daemon
  • cd 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, and test_archived_channel_list_is_tenant_scoped cover listing, role denial, and tenant isolation.
  • android/shared/.../MessagesRepositoryTest.kt pins the archived-list endpoint; MessagesScreenModelTest.kt covers restore success and failure preservation.
  • cd android && ./gradlew :shared:jvmTest --no-daemon
  • cd 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 carrying Accept: text/event-stream — the header every browser EventSource sends — because DRF content negotiation had no renderer for that media type. Added EventStreamRenderer (pass-through, media_type="text/event-stream") on MessagesEventStreamView.
  • resolve_sse_token used Redis GETDEL, which Redis < 6.2 rejects as an unknown command; the bare except turned that into a silent 401 for every token on prod. Now falls back to a pipelined GET+DEL on ResponseError, and both token helpers log with exc_info=True instead 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/messages now opts out of the global centered max-width shell 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-2N showed 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_signal call site corrected from views.py line 298 to line 330 (MessageFlagSignalView).
  • §2 feature checklist: GROUP_DM_MAX_PARTICIPANTS citation 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 400 for 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-conversation unread_count) are aggregated by frontend/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/messages now 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 messages namespace, plus pre-existing companies, hub, and today missing-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.tsx
  • cd frontend && pnpm test src/test/_shared/i18n.test.ts
  • cd frontend && pnpm build
  • node frontend/scripts/check-design-tokens.mjs
  • git 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:

  1. 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.

  2. Unread that doesn’t lie — MessageThread now calls markRead with 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.

  3. Optimistic send — MessageThread exposes addOptimistic/reconcile/failOptimistic via ref; sends echo instantly and reconcile on server ack (error_send toast + rollback on failure).

  4. @mention autocomplete — composer member-picker dropdown (Up/Down/Enter/Esc) inserting @Name.

  5. Design-token compliance — all five components/messaging/* files + MessagesPage.tsx migrated off raw var(--color-lumi-*)/bg-white to semantic Lumi tokens (Rule 4); check-design-tokens.mjs clean.

  6. Date separators + sender grouping in the thread (Today/Yesterday/date; consecutive same-sender within 5 min collapse the header).

  7. Jump-to-message highlight — MessageThread accepts highlightMessageId; clicking a search result scrolls to and flashes the message (ring-2 ring-primary, fades ~1.8s).

  8. 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 flagged field — the one open item for this surface).

  9. Topic digest — topic pane orders unread-first, shows an unread-topic count header and per-row relative last-activity time.

  10. 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_deleted BooleanField to Message model + migration 0003.
  • delete_message service now sets body_md = "tin nhắn đã xóa" and is_deleted = True.
  • MessageSerializer emits “(đã sửa)” marker in body_md when edited_at is set.
  • MessageReactView validates emoji against STANDARD_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.