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 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:

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:

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

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

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:

Known follow-ups

What was not established