Teams chats as rooms — one partition per chat
Every Microsoft Teams chat with at least one other person — one-to-one, group and meeting chats — is ingested into a partition of its own, once for all its members, and every member with a mesh account can read it. The live Teams source (Teams chats in the unified inbox) reads a person's chats from Graph on every view and stores nothing; a room is the stored, shared counterpart.
Owner's decisions (2026-10-25): "for teams rooms with more than one person, we should have its own partition for chats ==> this will ingest only once for all participants" · "we must have a dedicated partition by teams chat" — "with permissions for everyone in this chat" — "then we see chats in teams room in side index" · "ocr should be done at ingestion" (with language detection) — and then: OCR is part of the platform's STANDARD document ingest, its innermost step for every upload, run by a local vision model (a separate stream) · backfill "last 30 days or sth".
Code: src/MeshWeaver.Mail.MicrosoftGraph/TeamsChats/Rooms/. Tests:
src/MeshWeaver.Mail.MicrosoftGraph.Test/TeamsChatRoom{Tests,MeshTest,SearchScopeMeshTest}.cs.
The shape in the mesh
| path | node type | what |
|---|---|---|
TeamsChat_{16 hex} |
TeamsChatRoom |
the partition ROOT: chat id, type, topic, Teams link, members (each with the mesh user it resolved to), language hints, the ingestion watermark and lease, the newest message |
TeamsChat_{16 hex}/_Access/{user}_Access |
AccessAssignment |
one Viewer grant per member with a mesh account |
TeamsChat_{16 hex}/m{16 hex} |
TeamsChatMessage |
one message: sender, address, sent/modified (UTC), plain text, Teams link, file references, image count; a deleted message is a tombstone |
TeamsChat_{16 hex}/m{16 hex}/i{12 hex}-{ext} |
TeamsChatImage |
one inline image: where its bytes are (or why they are not), the page's picture reference and summary |
TeamsChat_{16 hex}/content/messages/m{16 hex}/i{12 hex}.png |
(file) | the image bytes, in the room's own content collection |
{user}/_ChatRooms/TeamsChat_{16 hex} |
TeamsChatMembership |
each member's pointer: the room's newest message and THEIR read state — what their inbox lists |
None of the four types keeps version history: a deleted Teams message is tombstoned here, and a history would keep the text the tombstone removed.
The partition name
TeamsChat_ + the first 16 hex characters of sha256(chatId). Derived, so every member's sync lands
on the same partition without a lookup; opaque, so neither the topic nor the members leak into a
partition or schema name; one segment and lower-case-safe (teamschat_…, 26 characters) for the
Postgres schema. Collision-safe by refusal: the root stores the full chat id, and a sync whose
chat id differs from the root's refuses to write (Refused), as it does for a root of that name that
is not a TeamsChatRoom at all (a Space, a home). 64 bits make the accidental case vanishingly rare;
the refusal makes it harmless.
Making the partition (as System, create-if-absent)
The pattern of core's mail-copy partition (Systemorph/MeshWeaver#7161, not depended on): every
IPartitionStorageProvider.EnsurePartitionProvisioned(room) → fail closed on a foreign root →
PartitionOwnership.PartitionDefinitionNode (Admin/Partition/{room}, routing) → the typed root.
No creator or admin grant is written — not for the syncing member, not for anyone; global admins
get nothing. 🚨 Before any grant the root is read back through its own stream
(GetMeshNodeStream(room), the owning hub's node — the lease is taken there): only a node that IS a
TeamsChatRoom carrying this chat id is leased, and every grant is written after it, so a grant never
attaches to a root a lagging index or mirror only claimed was there. The index check while provisioning
and this read-back each refuse a root of another chat on their own (pinned by
ARoomOfAnotherChat_IsNeverGranted_TheRootIsReadBackThroughItsOwnStream, each gate removed alone by
mutation, both removed: red).
Grants follow membership
Each pass reads GET /chats/{id}/members and matches each member to a mesh user:
- the syncing member by their token's
oid(whatever address their profile carries); - every other member by their Teams address through the portal's user index
(
UserIdentityCache, the index sign-in itself resolves email → user through) — one identity per address: a person who signs in with several identities is granted on the ONE user whose profile carries the address Teams reports; - a member without an account gets nothing.
The grants are then made to equal that set: a Viewer grant is created for a joiner, and a leaver's grant AND pointer are deleted. Viewer is all a member needs — every write runs as System.
A syncer whose OWN visible history starts after the room's ingests nothing and moves no cursor — they
would fill the room with only what they see and push the room-wide cursor past what they cannot; a member
who sees the whole history does the ingestion
(AHistoryRestrictedSyncer_IngestsNothing_AFullHistoryMemberThenBackfillsAll). A member who left and REJOINED without the history loses
the access they held on their own refused pass — pointer, then grant, under the lease
(AMemberWhoRejoinedWithoutHistory_LosesTheAccessTheyHeld). A departure clean-up is
serialized through the room's lease like every other write and re-confirmed by a listing read under it
(ADepartureCleanup_WaitsForTheLease_LikeEveryOtherWrite).
🚨 Visible history. A member whose Teams history starts LATER than the room's (visibleHistoryStartDateTime
after the room's historyFrom, its backfill start — someone added to the chat without its history) is
NOT granted: the room's grant is room-wide and it holds messages Teams never showed them. That rule relies
on a CREATION-time floor: the room never ingests a message SENT before historyFrom, even one edited inside
the modification window (AMessageSentBeforeTheRoomsHistory_IsNotIngested_EvenWhenEditedInsideTheWindow). They keep the
chat in their own live Teams leg. Per-message visibility is not built
(AJoinerWhoseVisibleHistoryStartsLater_IsNotGrantedTheRoom).
🚨 An unreadable directory revokes nobody: when the index cannot classify an address in time, the
pass keeps the room's stored matching and revokes nothing (AnUnreadableDirectory_RevokesNobody) —
but the syncing member, verified by their own token, is still granted, so the pointer they get leads to
a room they can open (OnAFirstSyncWithTheDirectoryDown_TheVerifiedSyncerIsStillGranted).
A chat reduced to the syncing member alone gets no NEW room, but an EXISTING room is still reconciled —
the members who left lose their grants and pointers (ARoomReducedToTheSyncerAlone_…). A syncer whose
own token is no longer in a COMPLETE listing has LEFT: their own grant and pointer go
(TheSoleConnectedMemberLeaving_LosesTheirOwnAccess) — they may be the only connected member, and then
no other pass would ever remove them.
🚨 Only a complete listing changes grants. A member listing that does not end within the page bound
fails the pass and touches no grant (AMemberListingCutShort_ChangesNoGrant). And the listing is READ
AGAIN after the lease is taken: the one that qualified the chat may predate a pass that ran in between,
and grants from that stale snapshot would restore a revoked member
(TheMembershipIsReadAgainUnderTheLease_AStaleSnapshotRestoresNobody). A leaver's pointer is deleted
BEFORE their grant, so a failure in between leaves the grant and the next pass retries.
Ingest once: the lease and the window
The root carries leaseHolder/leaseToken/leaseUntil. A pass takes the lease at the owning hub (an
atomic GetMeshNodeStream(room).Update) only when no lease is live — whoever holds it, the same member
included: every pass execution carries a token of its own, so two passes of one member (overlapping
ticks, two replicas) never share a lease, and only the token's pass commits or releases it. A live
lease ⇒ the pass reads and writes nothing (Leased). Any member with a connected Microsoft 365
credential may sync; whoever gets there first does the work for everyone. The pass KEEPS the lease
through its watermark commit and the publication of every pointer, and releases it only then — no other
pass can remove a member between the commit and the refresh.
What is read is a modification window of GET /chats/{id}/messages?$orderby=lastModifiedDateTime desc&$filter=lastModifiedDateTime gt {since} [and lastModifiedDateTime lt {upper}] — so edits and
deletions (both move lastModifiedDateTime) arrive with new messages:
- a new room:
since= now − 30 days (BackfillDays); - afterwards:
since=cursor, the newest modification already ingested; - a pass that runs out of pages (
MaxPagesPerPass× 50) records Graph's continuation link (pendingNextLink) andpendingTop; the next pass RESUMES there, so messages that share one modification instant across the page cut are reached rather than re-read for ever (MessagesSharingOneInstantAcrossThePageCut_AreAllReached). Should Graph no longer honour the link,pendingUpper(one millisecond above the oldest seen) bounds the re-read instead. Only the pass that finishes the window moves the cursor.
Writes are keyed by Graph's message id (m{sha256(id)[..16]}; the Graph id itself is kept on the node
as messageId). 🚨 A write never moves a message backwards: it is created when absent, else changed
at the owning hub only when the stored lastModifiedAt is not newer — so a pass that outlived its lease
cannot overwrite a newer edit or tombstone. An edit replaces the text; a deletion becomes a
tombstone (deleted: true, no text — the node stays, as Teams shows "this message was deleted"), and
its images go with it (below). The room's summary (lastMessage*, every pointer's preview) is
recomputed each pass from the stored newest messages and this pass's writes, so editing or deleting the
summarized message refreshes it. Tombstones are excluded IN the query, before its limit
(-content.deleted:true), here and in the inbox reading view — a run of deletions must not hide the live
messages behind it (ARunOfDeletions_DoesNotHideTheLiveMessagesBehindIt). Only revisions that LANDED feed
the summary; one refused because a newer one is stored does not.
Rebuilds fill alongside — never delete, then refill
TeamsChatRoomSync.RequestRebuild(chatId) sets rebuildFrom = now − 30 days. The room keeps serving
everything it holds; the rebuild re-reads its window over the same message ids (overwrites, nothing is
deleted), and the served cursor flips only when the rebuild reached the present. A rebuild requested
while a pass is running is not discarded by that pass: a pass commits a watermark only for the rebuild
it read for. This mirrors core's
fix for the mail copy (Systemorph/MeshWeaver#7166), where a rebuild had emptied an inbox. Pinned by
ARebuild_FillsAlongside_TheRoomIsNeverEmptied_AndTheCursorFlipsOnlyAtTheEnd, falsified by a
mutation that deletes before refilling.
Images: stored through the normal upload, read by the platform's ingest
An image in a message is the <img src=".../hostedContents/{id}/$value"> of its HTML body. Each pass
that reads a message makes its images follow it:
- every image it embeds that has no FINAL record is downloaded (
$value, bounded byMaxImageBytes), its type sniffed from its bytes, and stored in the room'scontentcollection through the normal content upload path —MeshOperations.Upload("{room}/content/messages/{m}/{i}{ext}"), as System, which raisesRaiseContentUploaded— then recorded as aTeamsChatImagechild of its message. 🚨 The upload notification is the point: it hands the image to the platform's document ingest, whose innermost step reads its text (OCR, by the shared local vision model) — owner's decision, 2026-10-25: OCR belongs to standard document ingest, for every upload. Nothing here reads the text itself, and nothing here sends an image to a model (AnImage_IsHandedToTheContentIngest_ThroughTheNormalUploadPath); - 🚨 fenced: a download can outlive its pass, so an image is stored and recorded only while that pass
still holds the lease and its message is still a live revision — otherwise nothing is recorded and bytes
already stored go again (
ADownloadThatOutlivesItsLease_RecordsNothing_AndLeavesNoFile); - a record is keyed by its hosted content: once a record is written, any record of the SAME hosted content
under another extension (a failed attempt recorded as
.png, the retry found a.jpg) is retired (AFailedImageRetriedAsAJpeg_LeavesNoStaleRecordBehind); - a record is final once its bytes are stored — a failed download or upload is RETRIED by the next pass that reads the message (a rebuild re-reads them all); a final record is never fetched again;
- an image the message no longer embeds — every image of a deleted message — loses its node AND its
file in the room's
contentcollection.
The image's page is a template bound to the image node ("Templates first, data later"): the picture
(Controls.Image bound to the node's imageSource, the room's content: reference) and, beside it, the
node's summary — a link to the file's content path, the neutral state "No transcription recorded for this image (OCR runs at ingest
when configured).", and the links to the message and "Open in Teams" — written at ingestion, so the area reads
nothing on the hub. The room's languageHints (declared per room, seeded from the creating member's locale)
are a hint for that ingest; nothing here consumes them yet.
The text, once indexed. The ingest writes an upload's text onto the file's Document node (the one
MeshDocumentSink writes under the collection, DocumentPaths.For("{room}/content", file)): ocrText
(with ocrLanguages, ocrEngine), or indexingGap saying why it was not indexed or read. The image page
binds two markdowns to that node by pointer — ocrText and indexingGap — so the text appears as soon as
the ingest writes it, with no read on the hub and no compile dependency on the ingest's types (the fields
are read by name). The Document's path is derived from the image node's OWN path: the image node id carries
the file's extension (i{12 hex}-{ext}), so TeamsChatRoomPaths.DocumentOfImage(path) needs nothing else.
Until either field exists, the summary's neutral state ("No transcription recorded for this image (OCR runs at ingest when configured).") stands. 🚨 Today's Document contract carries neither field (they land with the
platform's OCR and indexing-gap changes), so on today's platform the placeholder is what the page shows.
The inbox and the side index
- Side index. A room is a partition; its messages are its children, so its page's side index lists
them. Each message's
Orderisint.MaxValue − unix seconds(sentAt), and the index sortsOrderascending first — newest message first, independent of when it was written. - Inbox. One leg (
TeamsChatRoomInbox.Leg) lists the viewer's own pointers,namespace:{viewer}/_ChatRooms nodeType:TeamsChatMembership sort:content.receivedAt-desc— sorted by the NEWEST MESSAGE before the inbox's bound, never by the pointer's last write (a read mark, a rename or a backfill rewrites a quiet room's pointer); every pointer carries the field (TheRoomLeg_ListsByNewestMessage_NotByThePointersLastWrite) — a stored, anchored query, so a member with NO Teams connection of their own sees the rooms others ingested. SameTeamssource and theConversationscategory as the live leg and the bot conversations: one switch. Its connection query is the same anchor, so it appears once the viewer has a room. - Read state. A chat row is only ever unread or read, exactly like mail: each pointer carries
isReadand the leg namesTimeField: receivedAt.isReadis true when the newest message is the member's own, or their read mark is at or after it. The syncing member's mark comes from Teams —viewpoint.lastMessageReadDateTimeof their List chats row (the keeper reads it; Graph documentschatViewpointon List chats only, never onGET /chats/{id}) — for EXACTLY the chats the tick syncs, the listing paged until each is found, so a room beyond the newest page gets its mark too (ATeamsSideRead_ReachesARoomBeyondTheNewestBatch); anyone's mark moves when they open the room in the inbox reading view (TeamsChatRoomInboxItemReader, which reads AS the viewer — their grant decides) — 🚨 up to the newest message the view SHOWED, never to "now", merged at the pointer's owner (a message sent before the view opened but ingested after it still arrives unread). A read the store answered only in part is a refusal and marks nothing. A new message from someone else therefore shows as unread (pinned byANewMessageFromSomeoneElse_IsUnread_TheSendersOwnIsRead_AndOpeningMarksItRead, through core'sInboxRows.Of). The inbox's own chat-state filter (InboxPreferences.chatStates, core #7173) needs nothing more from the leg. - Nothing shows twice. The live leg reads the viewer's pointers first and leaves out every chat that
has a room (
TeamsChatsQueryProvider.HasRoom); a read of the pointers that fails lists the live chats whole (a chat shown twice is a nuisance, a chat hidden a loss). 🚨 While ingestion is OFF the rooms are frozen, so the live leg is NOT deduplicated — new messages would otherwise be invisible. - A pointer refresh MERGES at the pointer's owner: the read mark stored there (the member may have just opened the room) is kept, combined with the syncing member's mark from Teams — never overwritten by an earlier snapshot.
- The reading view shows every message that is not a tombstone — a file-only one by its files' names
(
📎 budget.xlsx), so the newest message is never invisible and opening the room never acknowledges past it (AFileOnlyNewestMessage_IsShown_AndOpeningReadsUpToIt). - Delete/Undo of a room row refuse, for the live chats' reason.
Top search: the Chats scope
Chats stay out of the default top search, like mail. ChatSearchScope (source:chats, label
Chats, off by default) declares TeamsChat_* and */_ChatRooms; the search turns each into
-namespace:{pattern} and -namespace:{pattern}/*.
🚨 The partition ROOT is not reached by a namespace filter — a top-level root's namespace is empty —
and a room root carries the chat's topic and newest message. Measured on a real mesh
(TeamsChatRoomSearchScopeMeshTest): with only the namespace terms the root TeamsChat_… WAS found by a
default search. So SearchScopes.ExclusionTokens now adds -path:{pattern} for every TOP-level pattern
(one without a /), and ParserExcludes requires it; the test now proves the root, its message and the
member's pointer stay out by default and come back with source:chats. The mail scope's *_MailCopy
pattern (Plugins #4186) is top-level too and gets the same root exclusion.
Configuration a deployment needs
| key | default | meaning |
|---|---|---|
Teams:ChatRooms:Enabled |
false |
ingest at all — 🚨 OFF until the operator declares it |
Teams:ChatRooms:BackfillDays |
30 |
how far back a new room is filled |
Teams:ChatRooms:PollInterval |
00:05:00 |
how often the keeper syncs the known members' chats |
Teams:ChatRooms:LeaseDuration |
00:10:00 |
how long one member's pass holds a room |
Teams:ChatRooms:MaxPagesPerPass |
20 |
pages of 50 per pass before the window is recorded as unfinished |
Teams:ChatRooms:ChatsPerTick |
50 |
how many of a member's most recent chats one tick looks at — and how many of their existing rooms |
Teams:ChatRooms:Concurrency |
2 |
rooms synced at once per member |
Teams:ChatRooms:MaxImageBytes |
15728640 |
the largest image stored |
OCR needs no configuration of its own here: it is the platform's document ingest's (config section
Ocr, owned by that stream). The consent is the existing Executive Assistant consent (Chat.Read); nobody connects again. The
portal must mount its writable content collection on partition roots (it does for every
single-segment node), or images record storeError instead of a file.
Who syncs, and when
TeamsChatRoomKeeper (a hosted service, inert while disabled) ticks every PollInterval and syncs,
for every known member: discovery — up to ChatsPerTick of their chats active within the
backfill window that have NO room yet (Graph is paged until that many are found, so a chat beyond the
first page still gets its room on a later tick); and reconciliation — the next ChatsPerTick of the
rooms they already have, read from their pointers as a keyset page (sort:path + path:>"cursor") from
a per-owner cursor that wraps at the end, so EVERY room is visited in turn, beyond any listing bound, and
a room whose chat went quiet (an edit, a deletion, a membership change creates no new message) is still
reconciled (TheRoster_VisitsEveryRoomInTurn_BeyondAnyPage). Discovery PROGRESSES: a chat whose sync this member cannot do (restricted
history, no other member, not a member, gone) is passed over for a day, so it does not take the discovery
slot on every tick (DiscoveryPassesOverARefusedChat_AndReachesTheNextOne). A member is
known once their live Teams chats were read since the process started (they opened their inbox). It
does not discover members on its own: after a restart a room catches up the next time any member
opens their inbox — the modification window is cumulative, so nothing is missed, only delayed (the
mail copy's keeper works the same way).
Not built
- Channels. A channel post needs
ChannelMessage.Read.All(admin consent) and a team/channel membership model (a channel's members are the team's, private channels their own). The design carries over —TeamsChannel_{digest}partition, grants from the team's membership, the same window and lease — but nothing of it is built. - WhatsApp and iMessage groups via the Mac bridges: the same shape (one partition per group, grants per member with an account) would apply, but those bridges expose one person's view and no member directory; not designed further here.
- File attachments are recorded by reference (name, URL); their bytes are not copied.
- Reactions, replies-to and mentions are not modelled.
- Reading an image's indexed text back onto its page — the placeholder stands until a sanctioned API exists.
- Discovery after a restart without a member visiting (see above).
Follow-up: prepared replies (not built)
The reply pre-drafter (feat/reply-pre-drafter, not merged) will want the rooms. Its contract, recorded
here so the rooms keep what it needs:
- it writes
TriageVerdicts withProvider = "teams"throughITriageVerdictStore, withMessageId= the Graph chat message's provider-stable id — never a copy path or a digest. Every message node keeps that id (TeamsChatMessage.messageId) for exactly this; - it registers an
IReplyPreparer(MeshWeaver.AI.Replies) withProvider = "teams"and the methodsRead/FindExisting/Create/Amend/ConnectHint; - the preparer never posts: it stores the prepared text on the chat item;
- one prepared reply per item, amended rather than drafted again;
Amendre-reads inside the write and returnsLeftAlonewhen the person replied or edited afterpreparedAt, judged by Teams' own clock;- only the person's own delegated credential is used;
- the reference implementation is
OutlookReplyPreparer.
Known follow-ups
- Show the ingest's
ocrText/indexingGapon the image page as soon as the platform'sDocumentcontract carries them (the page already binds both by name; nothing to change here unless the names change). - A scripted Graph that mirrors more of
/me/chats(ordering by last message,$orderby) for the keeper's tests. - Per-message history visibility, so a history-restricted member could be granted the part they see.
What was not established
- Graph's behaviour was exercised only through a scripted transport: that
GET /chats/{id}returns the caller'sviewpoint, that$filter=lastModifiedDateTime gt … and … lt …with$orderby=lastModifiedDateTime descis accepted on/chats/{id}/messagesfor every chat type, and that$valueof a hosted content is the raw image, and that a messages@odata.nextLinkstays valid between two passes, follow Microsoft's documentation and were not read against a live tenant. - The volume on a large tenant (members × chats × polls) was not measured;
ChatsPerTick,ConcurrencyandPollIntervalare first guesses. - That the portal's
contentmount on a root is writable for a System upload — and deletable — on every storage backend was proven on the file system only. - The live leg's de-duplication reads at most 500 of a viewer's pointers; a viewer in more rooms than that may see a chat twice (shown, never hidden).
- That the platform's document ingest does read a chat image's text once indexed is the ingest's contract, not tested here; what is tested is that the upload notification reaches the ingest seam.
- The
ImpersonationGuardclassifies a registry-served module assembly; inEnforcemode a module'sRunAsSystemmight be refused — the other modules that write as System (Gmail broker, indexing) share that exposure; not measured.