@open-everywhere/provider-teams 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +46 -2
  4. package/agent/channels/teams-bot-name.ts +162 -0
  5. package/agent/channels/teams-catch-up.ts +244 -0
  6. package/agent/channels/teams-connector.ts +183 -0
  7. package/agent/channels/teams-conversation-ref.ts +398 -0
  8. package/agent/channels/teams-directory.ts +193 -0
  9. package/agent/channels/teams-engagement.ts +391 -0
  10. package/agent/channels/teams-fire-eligibility.ts +152 -0
  11. package/agent/channels/teams-front.ts +1404 -0
  12. package/agent/channels/teams-graph-boundary.ts +178 -0
  13. package/agent/channels/teams-graph-team-id.ts +106 -0
  14. package/agent/channels/teams-id-restore.ts +131 -0
  15. package/agent/channels/teams-identity.ts +395 -0
  16. package/agent/channels/teams-input-response.ts +129 -0
  17. package/agent/channels/teams-message-ops.ts +79 -0
  18. package/agent/channels/teams-origin-codec.ts +104 -0
  19. package/agent/channels/teams-post-arm.ts +323 -0
  20. package/agent/channels/teams-reaction-feedback.ts +127 -0
  21. package/agent/channels/teams-read.ts +510 -0
  22. package/agent/channels/teams-roster.ts +136 -0
  23. package/agent/channels/teams-stop.ts +319 -0
  24. package/agent/channels/teams-supervisor.ts +638 -0
  25. package/agent/channels/teams.ts +270 -0
  26. package/agent/contributions.ts +31 -0
  27. package/agent/docs-generated.ts +15 -0
  28. package/agent/ops.ts +129 -0
  29. package/agent/provider-events.ts +69 -0
  30. package/agent/register.ts +219 -0
  31. package/agent/surface-parity.ts +112 -0
  32. package/dist/agent/channels/teams-bot-name.d.ts +36 -0
  33. package/dist/agent/channels/teams-bot-name.d.ts.map +1 -0
  34. package/dist/agent/channels/teams-bot-name.js +141 -0
  35. package/dist/agent/channels/teams-bot-name.js.map +1 -0
  36. package/dist/agent/channels/teams-catch-up.d.ts +53 -0
  37. package/dist/agent/channels/teams-catch-up.d.ts.map +1 -0
  38. package/dist/agent/channels/teams-catch-up.js +221 -0
  39. package/dist/agent/channels/teams-catch-up.js.map +1 -0
  40. package/dist/agent/channels/teams-connector.d.ts +57 -0
  41. package/dist/agent/channels/teams-connector.d.ts.map +1 -0
  42. package/dist/agent/channels/teams-connector.js +132 -0
  43. package/dist/agent/channels/teams-connector.js.map +1 -0
  44. package/dist/agent/channels/teams-conversation-ref.d.ts +123 -0
  45. package/dist/agent/channels/teams-conversation-ref.d.ts.map +1 -0
  46. package/dist/agent/channels/teams-conversation-ref.js +368 -0
  47. package/dist/agent/channels/teams-conversation-ref.js.map +1 -0
  48. package/dist/agent/channels/teams-directory.d.ts +11 -0
  49. package/dist/agent/channels/teams-directory.d.ts.map +1 -0
  50. package/dist/agent/channels/teams-directory.js +179 -0
  51. package/dist/agent/channels/teams-directory.js.map +1 -0
  52. package/dist/agent/channels/teams-engagement.d.ts +174 -0
  53. package/dist/agent/channels/teams-engagement.d.ts.map +1 -0
  54. package/dist/agent/channels/teams-engagement.js +336 -0
  55. package/dist/agent/channels/teams-engagement.js.map +1 -0
  56. package/dist/agent/channels/teams-fire-eligibility.d.ts +26 -0
  57. package/dist/agent/channels/teams-fire-eligibility.d.ts.map +1 -0
  58. package/dist/agent/channels/teams-fire-eligibility.js +126 -0
  59. package/dist/agent/channels/teams-fire-eligibility.js.map +1 -0
  60. package/dist/agent/channels/teams-front.d.ts +249 -0
  61. package/dist/agent/channels/teams-front.d.ts.map +1 -0
  62. package/dist/agent/channels/teams-front.js +1143 -0
  63. package/dist/agent/channels/teams-front.js.map +1 -0
  64. package/dist/agent/channels/teams-graph-boundary.d.ts +45 -0
  65. package/dist/agent/channels/teams-graph-boundary.d.ts.map +1 -0
  66. package/dist/agent/channels/teams-graph-boundary.js +168 -0
  67. package/dist/agent/channels/teams-graph-boundary.js.map +1 -0
  68. package/dist/agent/channels/teams-graph-team-id.d.ts +30 -0
  69. package/dist/agent/channels/teams-graph-team-id.d.ts.map +1 -0
  70. package/dist/agent/channels/teams-graph-team-id.js +89 -0
  71. package/dist/agent/channels/teams-graph-team-id.js.map +1 -0
  72. package/dist/agent/channels/teams-id-restore.d.ts +34 -0
  73. package/dist/agent/channels/teams-id-restore.d.ts.map +1 -0
  74. package/dist/agent/channels/teams-id-restore.js +123 -0
  75. package/dist/agent/channels/teams-id-restore.js.map +1 -0
  76. package/dist/agent/channels/teams-identity.d.ts +148 -0
  77. package/dist/agent/channels/teams-identity.d.ts.map +1 -0
  78. package/dist/agent/channels/teams-identity.js +336 -0
  79. package/dist/agent/channels/teams-identity.js.map +1 -0
  80. package/dist/agent/channels/teams-input-response.d.ts +23 -0
  81. package/dist/agent/channels/teams-input-response.d.ts.map +1 -0
  82. package/dist/agent/channels/teams-input-response.js +102 -0
  83. package/dist/agent/channels/teams-input-response.js.map +1 -0
  84. package/dist/agent/channels/teams-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/teams-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/teams-message-ops.js +72 -0
  87. package/dist/agent/channels/teams-message-ops.js.map +1 -0
  88. package/dist/agent/channels/teams-origin-codec.d.ts +16 -0
  89. package/dist/agent/channels/teams-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/teams-origin-codec.js +99 -0
  91. package/dist/agent/channels/teams-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/teams-post-arm.d.ts +37 -0
  93. package/dist/agent/channels/teams-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/teams-post-arm.js +280 -0
  95. package/dist/agent/channels/teams-post-arm.js.map +1 -0
  96. package/dist/agent/channels/teams-reaction-feedback.d.ts +22 -0
  97. package/dist/agent/channels/teams-reaction-feedback.d.ts.map +1 -0
  98. package/dist/agent/channels/teams-reaction-feedback.js +108 -0
  99. package/dist/agent/channels/teams-reaction-feedback.js.map +1 -0
  100. package/dist/agent/channels/teams-read.d.ts +127 -0
  101. package/dist/agent/channels/teams-read.d.ts.map +1 -0
  102. package/dist/agent/channels/teams-read.js +424 -0
  103. package/dist/agent/channels/teams-read.js.map +1 -0
  104. package/dist/agent/channels/teams-roster.d.ts +54 -0
  105. package/dist/agent/channels/teams-roster.d.ts.map +1 -0
  106. package/dist/agent/channels/teams-roster.js +121 -0
  107. package/dist/agent/channels/teams-roster.js.map +1 -0
  108. package/dist/agent/channels/teams-stop.d.ts +74 -0
  109. package/dist/agent/channels/teams-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/teams-stop.js +252 -0
  111. package/dist/agent/channels/teams-stop.js.map +1 -0
  112. package/dist/agent/channels/teams-supervisor.d.ts +351 -0
  113. package/dist/agent/channels/teams-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/teams-supervisor.js +366 -0
  115. package/dist/agent/channels/teams-supervisor.js.map +1 -0
  116. package/dist/agent/channels/teams.d.ts +5 -0
  117. package/dist/agent/channels/teams.d.ts.map +1 -0
  118. package/dist/agent/channels/teams.js +246 -0
  119. package/dist/agent/channels/teams.js.map +1 -0
  120. package/dist/agent/contributions.d.ts +12 -0
  121. package/dist/agent/contributions.d.ts.map +1 -0
  122. package/dist/agent/contributions.js +31 -0
  123. package/dist/agent/contributions.js.map +1 -0
  124. package/dist/agent/docs-generated.d.ts +3 -0
  125. package/dist/agent/docs-generated.d.ts.map +1 -0
  126. package/dist/agent/docs-generated.js +13 -0
  127. package/dist/agent/docs-generated.js.map +1 -0
  128. package/dist/agent/ops.d.ts +14 -0
  129. package/dist/agent/ops.d.ts.map +1 -0
  130. package/dist/agent/ops.js +122 -0
  131. package/dist/agent/ops.js.map +1 -0
  132. package/dist/agent/provider-events.d.ts +3 -0
  133. package/dist/agent/provider-events.d.ts.map +1 -0
  134. package/dist/agent/provider-events.js +67 -0
  135. package/dist/agent/provider-events.js.map +1 -0
  136. package/dist/agent/register.d.ts +16 -0
  137. package/dist/agent/register.d.ts.map +1 -0
  138. package/dist/agent/register.js +198 -0
  139. package/dist/agent/register.js.map +1 -0
  140. package/dist/agent/surface-parity.d.ts +3 -0
  141. package/dist/agent/surface-parity.d.ts.map +1 -0
  142. package/dist/agent/surface-parity.js +106 -0
  143. package/dist/agent/surface-parity.js.map +1 -0
  144. package/package.json +183 -4
  145. package/tsconfig.build.json +22 -0
  146. package/tsconfig.json +15 -0
@@ -0,0 +1,1143 @@
1
+ // The Teams FRONT route — the single-window legs' live wiring, porting slack-front.ts's proven
2
+ // shape to Teams (build order: single-window-generalisation-strategy.md task #46, Teams arm).
3
+ //
4
+ // WHY A FRONT (same reason as Slack): eve's `teamsChannel` owns the public Bot-Framework webhook
5
+ // and dispatches every message activity to its own conversation-session machinery; it exposes no
6
+ // hook that can DIVERT a wake into another channel (`onMessage` has no cross-channel `to`).
7
+ // So the public URL is fronted: the wrapped `teamsChannel` is mounted at an INTERNAL route
8
+ // ({@link INTERNAL_TEAMS_ROUTE}) and OUR route sits at the public {@link PUBLIC_TEAMS_ROUTE}.
9
+ // Both routes live on the SAME channel object (one file slug → one channel name → one session
10
+ // namespace), exactly the slack-front composition.
11
+ //
12
+ // The front route:
13
+ // 1. Verifies the inbound request (eve's PUBLIC `verifyTeamsRequest` — the Bot Connector JWT
14
+ // against Microsoft's OpenID keys + our app id; fail-closed 401). Unlike the Slack front we
15
+ // don't re-implement the check: eve exports it. The forward path re-verifies inside
16
+ // teamsChannel (a fresh Request), so the native path's auth is unchanged — we only ADD a
17
+ // gate, never weaken one.
18
+ // 2. Parses the activity (eve's public `parseTeamsActivity`). Anything that isn't a message
19
+ // activity — invokes (HITL card clicks), conversationUpdates, unparseable bodies — FORWARDS
20
+ // to teamsChannel untouched (the fail-safe default).
21
+ // 3. For a message activity, resolves the SINGLE-WINDOW plan (teams-supervisor.ts
22
+ // planTeamsSingleWindow: grain singleWindow + the fail-closed Teams privacy floor). NO plan
23
+ // → FORWARD (byte-identical: with the window gates off, every activity takes today's
24
+ // native path). A plan → the activity ITSELF is diverted into the unified window —
25
+ // SINGLE-LEG: Teams fires ONE webhook per message and the floor needs no network, so one
26
+ // synchronous verdict drives both the native suppression (we don't forward) and the
27
+ // addressed/ambient wake (we dispatch) — the Slack build's lost-mention window (two legs
28
+ // disagreeing across webhooks/caches) cannot exist here by construction.
29
+ // 4. Idempotency (two-layer, mirroring slack-front: in-memory + the durable operational store)
30
+ // and per-container FIFO guard the wake.
31
+ //
32
+ // LEG A here is the Teams AMBIENT ingest: a non-mention channel message on a single-window
33
+ // domain becomes a judged channel-watch wake in the shared window. NOTE the platform reality:
34
+ // Teams only delivers non-mention messages to a bot whose app carries the matching RSC permission
35
+ // — `ChannelMessage.Read.Group` for a team channel, `ChatMessage.Read.Chat` for a chat (both are
36
+ // in teams-app/manifest.json). Without the relevant one, only @mention activities arrive and Leg A
37
+ // is simply never exercised (no code path depends on it firing).
38
+ //
39
+ // CHAT SCOPE IS DELIBERATELY NOT LEG A. The plan's privacy floor (teams-supervisor.ts
40
+ // teamsSingleWindowPrivacyOk) admits `scope === "channel"` only, so a group chat never joins the
41
+ // shared cross-surface window. That is the intended posture, not an oversight: a group chat is a
42
+ // per-person surface, not an org container, so admitting one would breach the floor's whole premise
43
+ // (see the floor's docstring). Widening it is a security decision that needs its own review, not a
44
+ // side effect of a manifest line.
45
+ //
46
+ // LEG E is the AMBIENT ENGAGEMENT SEAM (OPE-1500, contract §2.5) and it covers BOTH containers a
47
+ // Teams engagement can be kept per: a channel's reply chain and a group chat. An @mention marks the
48
+ // conversation engaged, a later plain message in it wakes the agent, and `stand_down` stores the
49
+ // stop. Leg E dispatches nothing itself — it marks, and it suppresses a stood-down conversation
50
+ // above every other branch — so no message is ever handled by both legs. The two legs answer
51
+ // different questions: Leg A is a DOMAIN's opted-in ambient posture (a vouched single-window
52
+ // channel), Leg E is a CONVERSATION's own invitation, and it needs no domain opt-in at all.
53
+ //
54
+ // Both legs depend on the SAME platform grant, and Leg E states it out loud: Microsoft delivers
55
+ // non-mention activities only where the tenant consented to `ChannelMessage.Read.Group` (a channel)
56
+ // or `ChatMessage.Read.Chat` (a chat). Nothing can probe that consent, so teams-engagement.ts proves
57
+ // the grant present on the first non-mention message and logs the named reason once until then.
58
+ import { POST } from "eve/channels";
59
+ import { verifyTeamsRequest, parseTeamsActivity, teamsThreadRootActivityId, isTeamsInputResponseActivity } from "eve/channels/teams";
60
+ import { sendTeamsActivityBackedOff } from "./teams-connector.js";
61
+ import { buildTeamsAuth, teamsMembershipType, teamsTrustGroup, teamsUserTypeFromMember, withTeamsUserType, } from "./teams-identity.js";
62
+ import { resolveTeamsUserType, teamsServiceUrlRosterProbe } from "./teams-roster.js";
63
+ import { recordTeamsConversationReference, teamsChannelGrainConversationId, teamsReplyChainRoot } from "./teams-conversation-ref.js";
64
+ import { parseTeamsReaction, routeTeamsStopReaction } from "./teams-stop.js";
65
+ import { clearTeamsRoomStop, markTeamsEngaged, markTeamsRoomWatched, resolveTeamsEngagement, teamsMachineAuthored, teamsRoomWatchStopped, } from "./teams-engagement.js";
66
+ import { teamsGraphTeamId } from "./teams-graph-team-id.js";
67
+ import { planTeamsSingleWindow, handleTeamsSingleWindowDispatch, teamsSupervisorToken, buildTeamsDmWake, } from "./teams-supervisor.js";
68
+ import { shouldRotate, EventDedupe, ThreadFifo, divertToUnifiedWindow, divertInboundToUnifiedWindow, supervisorGrainFor, mintRotationToken, claimRuntimeRotation, stampMintedRuntime, grainUsesUnifiedWindow, resolvePartition, domainKeyOf, engagementTierFor, getConfig, getOperationalStore, isVouchedSurface, trustGroupKeyFor, resolveWakeTrustFromContext, stampNonMemberChatAuth, deploymentOrg, appendAudit, getOrCreateDmSeat, touchDmSeat, renderWake, mintWakeId, resolveExternalPolicy, externalPolicyOverrideFor, externalPolicyIsInert, withholdsBody, roomIsSilenced, silentRoomNotice, silentNoticeKey, EXTERNAL_SENDER_NOTICE, EXTERNAL_MENTION_ACK, owesSuppressedMentionAck, suppressionRendering, carryTailForward, recordWakeTail, tailAuthorGrain, dmWindowRegistryKey, RateLimitExhaustedError, receiptEventTime, spreadVendorTs, seatCatchUp, } from "@open-everywhere/core/agent/adapter-api.js";
69
+ /** The public URL the Bot Framework POSTs every activity to (the bot registration's endpoint). */
70
+ export const PUBLIC_TEAMS_ROUTE = "/eve/v1/teams";
71
+ /** The internal path the wrapped `teamsChannel` is mounted at (never hit by Teams directly). */
72
+ export const INTERNAL_TEAMS_ROUTE = "/eve/v1/teams/native";
73
+ function tlog(msg) {
74
+ console.log(`[open-everywhere:teams-front] ${msg}`);
75
+ }
76
+ /** One auth bag's raw attributes, defensively — the bags this file handles arrive in two shapes
77
+ * (`OpenEverywhereTeamsAuth` and the erased attribute-bag the DM divert carries). */
78
+ function teamsAttrs(auth) {
79
+ const a = auth?.attributes;
80
+ return a && typeof a === "object" ? a : {};
81
+ }
82
+ /**
83
+ * Did Teams POSITIVELY refute this author's membership (OPE-489)? Only a resolved `userRole: "guest"`
84
+ * counts. Absent is NOT a refutation: absence of evidence, and this leg fails OPEN.
85
+ *
86
+ * The auth bag carries the verdict once {@link teamsAuthWithUserType} has folded the roster probe's
87
+ * answer in (OPE-493). Both dispatch paths do that unconditionally; the pre-ack
88
+ * {@link liveTeamsExternalGate} does it only when the policy could act on the answer.
89
+ *
90
+ * The verdict routes through `teamsUserTypeFromMember`, the ONE reader of Teams' guest vocabulary,
91
+ * rather than re-spelling the string comparison here. A second reader is how "what counts as a
92
+ * guest" comes to mean two things — and the vocabulary is already subtle (Graph's
93
+ * `conversationMember.roles` gives an OUT-OF-TENANT external the value `owner`).
94
+ */
95
+ function teamsAuthorRefuted(auth) {
96
+ const a = teamsAttrs(auth);
97
+ return teamsUserTypeFromMember({ userType: a["user_type"] ?? a["userType"] }) === "guest";
98
+ }
99
+ /** The trust registry's GROUP-rung key for this activity's TEAM (OPE-490), so a `team:<id>`
100
+ * declaration reaches the room leg exactly as it reaches the binding. Absent on a chat with no
101
+ * team, and on a team id that is not a safe key segment (refused, never repaired). */
102
+ function teamsRoomGroupKey(auth) {
103
+ const a = teamsAttrs(auth);
104
+ const tenant = a["tenant_id"] ?? a["tenantId"];
105
+ const group = teamsTrustGroup(typeof (a["team_id"] ?? a["teamId"]) === "string" ? String(a["team_id"] ?? a["teamId"]) : undefined);
106
+ if (typeof tenant !== "string" || tenant.length === 0 || !group)
107
+ return {};
108
+ return { groupKey: `teams/${tenant}/${group.trustGroup}` };
109
+ }
110
+ /**
111
+ * The auth capture for one inbound activity — the SAME raw-fact stow teams.ts's onMessage builds,
112
+ * so the diverted wake binds identically to a native turn (identity seam unchanged).
113
+ *
114
+ * SYNCHRONOUS, so it carries no `userType`: the roster verdict is a network call. The two dispatch
115
+ * paths fold it in afterwards with {@link teamsAuthWithUserType}.
116
+ */
117
+ function authForActivity(a) {
118
+ return buildTeamsAuth({
119
+ userId: a.from.id,
120
+ aadObjectId: a.from.aadObjectId,
121
+ userName: a.from.name,
122
+ tenantId: a.tenantId ?? a.conversation.tenantId,
123
+ conversationId: a.conversation.id,
124
+ channelId: a.teamsChannelId,
125
+ teamId: a.teamId,
126
+ serviceUrl: a.serviceUrl,
127
+ scope: a.scope,
128
+ membershipType: teamsMembershipType(a.channelData),
129
+ });
130
+ }
131
+ async function teamsAuthWithUserType(auth, a, probeFor) {
132
+ const factory = probeFor ?? ((activity) => teamsServiceUrlRosterProbe(activity.serviceUrl));
133
+ try {
134
+ return withTeamsUserType(auth, await resolveTeamsUserType(factory(a), a.conversation.id, a.from.id));
135
+ }
136
+ catch {
137
+ return auth; // no verdict → byte-identical capture
138
+ }
139
+ }
140
+ // --- THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain, OPE-504 room grain) ---------------
141
+ //
142
+ // Teams is the strongest adopter after Discord and Notion, and it is the one surface that can answer
143
+ // BOTH grains from vendor evidence: the Bot Connector roster reports `userRole: "guest"` for the
144
+ // author leg (teams-roster.ts), and a Teams
145
+ // SHARED CHANNEL is a positive vendor-external verdict for the room leg. The decision plane is the
146
+ // shared, surface-agnostic access/external-policy.ts — this file contributes only the Teams facts and
147
+ // the Teams consequences.
148
+ //
149
+ // 🔴 THE GATE SITS IN THE FRONT, ABOVE BOTH BRANCHES, WHICH IS WHY IT COVERS THE NATIVE PATH TOO.
150
+ // A suppressed activity is never forwarded to the wrapped `teamsChannel`, so a deployment with the
151
+ // window gates OFF is protected by the same verdict as one with them on. Putting the gate inside
152
+ // `liveTeamsDispatch` would have left every non-single-window Teams deployment unwired.
153
+ /**
154
+ * The resolved external-participant policy for one room: the ORG DEFAULT, with the DOMAIN OVERRIDE
155
+ * applied when the domain registry can be read. An unreadable registry degrades to the org default —
156
+ * today's behaviour — rather than throwing, exactly as discord-gateway.ts's `externalPolicyForRoom`
157
+ * does and for the same reason: the policy plane must never be the thing that takes a surface off
158
+ * the air.
159
+ */
160
+ function teamsExternalPolicyForRoom(roomKey) {
161
+ try {
162
+ return resolveExternalPolicy(externalPolicyOverrideFor(roomKey, getConfig().loadDomains()));
163
+ }
164
+ catch {
165
+ return resolveExternalPolicy();
166
+ }
167
+ }
168
+ /**
169
+ * The AUTHOR-TRUST question for one activity (OPE-516) — the OPERATOR's own declaration of who is
170
+ * internal in this tenant.
171
+ *
172
+ * 🔴 TEAMS NEEDS IT EVEN THOUGH TEAMS CAN REFUTE. The surface refutation (`teamsAuthorRefuted`) reads
173
+ * a resolved roster `userRole`, which every front path now resolves (OPE-493). It still does not
174
+ * answer everywhere: a tenant whose bot credential cannot read the roster gets no verdict, and the
175
+ * probe only ever REFUTES — it never declares anyone internal. The register is the leg that covers
176
+ * those. It is additive: an unarmed tenant declares nobody and the leg contributes nothing, so a
177
+ * deployment that has not armed a register sees no behaviour change at all.
178
+ *
179
+ * Returns `{}` when the tenant is unreadable — no tenant key, no arming key, no question to ask.
180
+ */
181
+ export function teamsAuthorFacts(auth, authorId) {
182
+ const a = teamsAttrs(auth);
183
+ const tenant = a["tenant_id"] ?? a["tenantId"];
184
+ if (typeof tenant !== "string" || tenant.length === 0)
185
+ return {};
186
+ const group = teamsTrustGroup(typeof (a["team_id"] ?? a["teamId"]) === "string" ? String(a["team_id"] ?? a["teamId"]) : undefined);
187
+ return {
188
+ authorTrust: {
189
+ tenantKey: `teams/${tenant}`,
190
+ ...(authorId ? { authorId } : {}),
191
+ // The GROUP-rung key this author holds, in the register's own pattern grammar. Teams gives one
192
+ // rung (the team), not a role list — a member of the team holds `teams/<tenant>/team:<id>`.
193
+ ...(group ? { memberships: [`teams/${tenant}/${group.trustGroup}`] } : {}),
194
+ },
195
+ };
196
+ }
197
+ /**
198
+ * The SURFACE's own vendor-external verdict for one activity: this room spans another organisation.
199
+ *
200
+ * A Teams SHARED CHANNEL (`channelData.channel.membershipType === "shared"`) is exactly that — it can
201
+ * hold members from tenants that are not ours, which is why teams-identity.ts already demotes one to
202
+ * `external` for the memory tier. Absent or any other value means "not reported", which includes "the
203
+ * activity did not carry channelData": absence of evidence, and this leg fails OPEN.
204
+ */
205
+ export function teamsVendorExternal(a) {
206
+ return teamsMembershipType(a.channelData) === "shared";
207
+ }
208
+ export function teamsExternalVerdict(input) {
209
+ if (withholdsBody(input.author, input.policy))
210
+ return input.addressed ? "withheld-addressed" : "withheld-ambient";
211
+ const room = input.isDm ? { dm: true } : { surfaceKey: input.roomKey, ...(input.groupKey === undefined ? {} : { groupKey: input.groupKey }) };
212
+ return roomIsSilenced({ room, ...(input.vendorExternal ? { vendorExternal: true } : {}), ingested: [input.author] }, input.policy)
213
+ ? "silent-room"
214
+ : "proceed";
215
+ }
216
+ /**
217
+ * Best-effort Bot-Framework send into the activity's own conversation. Never throws: a notice is
218
+ * an affordance, and a failed one must not turn a suppressed message into a 500 the Bot Framework
219
+ * retries. Answers whether the send was accepted, which the OPE-885 ack fallback reads. The
220
+ * serviceUrl is re-validated by the transport arm's own host allowlist.
221
+ *
222
+ * 🔴 A TIGHTENED RETRY POLICY, NOT THE SHARED DEFAULTS (cold review finding, OPE-1237). This
223
+ * function is reached from `liveTeamsExternalGate`, which `dispatchMessage` (:954) AWAITS before
224
+ * returning `OK()` on the route Microsoft posts activities to — the same "latency-critical path"
225
+ * shape `slack-web-api.ts`'s `PROBE_OPERATIONS` exists for. The Bot Framework redelivers on a slow
226
+ * ack (this file's own dedupe comment states redelivery is FAIL-OPEN), so the shared 60 s budget
227
+ * would turn one Teams rate-limit episode into a redelivery storm instead of a lost notice. Two
228
+ * attempts at a 2 s cap, mirroring `auth.test`'s policy exactly.
229
+ */
230
+ async function postTeamsNotice(a, text) {
231
+ try {
232
+ await sendTeamsActivityBackedOff({ serviceUrl: a.serviceUrl, conversationId: a.conversation.id, body: { type: "message", text } }, { retry: { maxAttempts: 2, capMs: 2_000 } });
233
+ return true;
234
+ }
235
+ catch (err) {
236
+ // 🔴 LOG A RATE-LIMIT EXHAUSTION BEFORE SWALLOWING IT (OPE-1237), mirroring Slack's
237
+ // slack-events.ts intro fetch. This catch is fail-soft by design, and without this line an
238
+ // exhausted 429 retry would leave NO trace anywhere — the silent drop the ticket exists to
239
+ // prevent. Every field is token-free.
240
+ if (err instanceof RateLimitExhaustedError) {
241
+ console.warn(`[open-everywhere:teams] rate limited: conversations.activities.send gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
242
+ }
243
+ return false; // silence — see the caller's own no-fallback reasoning
244
+ }
245
+ }
246
+ /**
247
+ * Tell an EXCLUDED sender that the agent will not act on their message (behaviour 1). Answers
248
+ * whether the notice reached them.
249
+ *
250
+ * 🔴 THIS NOTICE NEVER GOES TO A TEAM CHANNEL, AND THAT RULING STANDS — the same one Discord's
251
+ * `postExternalSenderNotice` carries. Its text names the organisation, so posting it into the
252
+ * channel would announce that this person is classified as an outsider: a disclosure about a member
253
+ * of the room, not a notice to the sender. Teams cannot open a 1:1 with a person the bot has never
254
+ * met (the Bot Framework needs a conversation reference captured on a prior inbound turn), so there
255
+ * is no private channel to fall back to. A PERSONAL chat is itself private, so the notice posts
256
+ * there.
257
+ *
258
+ * 🔴 SO A CHANNEL @MENTION ALWAYS ANSWERS `false`, AND THAT IS THE COMMON CASE (OPE-885). The caller
259
+ * then posts the fixed `EXTERNAL_MENTION_ACK` into the channel instead — a different string, which
260
+ * names no person, no organisation and no verdict, and is therefore not the disclosure this notice
261
+ * would have been.
262
+ */
263
+ async function postTeamsExternalSenderNotice(a) {
264
+ if (a.scope !== "personal")
265
+ return false;
266
+ return await postTeamsNotice(a, EXTERNAL_SENDER_NOTICE);
267
+ }
268
+ /**
269
+ * Post the suppressed-wake half of the silent-room announcement pair (OPE-504) — AT MOST ONCE PER
270
+ * ROOM, EVER.
271
+ *
272
+ * The claim is DURABLE, not per-process: `claimNotice` writes a permanent row, where an in-memory
273
+ * marker would re-announce on every cold start — and a cold start happens per serverless invocation,
274
+ * which would be the periodic announcement the design rejects. FAIL-CLOSED on the claim: if the store
275
+ * cannot prove the notice is unsaid, the agent stays quiet.
276
+ */
277
+ async function announceTeamsSilentRoom(a, org, surfaceKey) {
278
+ if (!org)
279
+ return false;
280
+ try {
281
+ const won = await getOperationalStore().claimNotice(org, silentNoticeKey(surfaceKey, "suppressed-wake"), new Date().toISOString());
282
+ if (!won)
283
+ return false; // already said, once, forever
284
+ }
285
+ catch {
286
+ return false; // cannot prove it is unsaid ⇒ stay quiet
287
+ }
288
+ return await postTeamsNotice(a, silentRoomNotice("suppressed-wake"));
289
+ }
290
+ /**
291
+ * The LIVE gate: resolve the verdict for one verified message activity and pay what it owes.
292
+ * `true` means SUPPRESSED — the caller acks and forwards nothing.
293
+ *
294
+ * FAIL-OPEN on an unresolvable identity. If the auth capture or the partition cannot be built there
295
+ * is no room key, no tenant and no classification to act on, so the activity takes today's path and
296
+ * the existing branches apply their own fail-closed rules. Every carriage leg in this plane fails
297
+ * open, and this one has to as well: a config error must not silently take Teams off the air.
298
+ *
299
+ * 🔴 THE ROSTER PROBE IS PAID FOR ONLY WHEN THE POLICY CAN USE IT. This gate runs BEFORE the 200 ack,
300
+ * so a Bot Connector GET here is latency on EVERY activity — including the ones the front only
301
+ * forwards — and a slow ack makes the Bot Framework redeliver. The shipped grains (`taint` / `engage`)
302
+ * can suppress nothing, so `externalPolicyIsInert` skips the probe entirely and an un-opted-in
303
+ * deployment pays exactly what it paid before. An opted-in one pays one cached GET per
304
+ * (conversation, user) per TTL. This is the same trade `slack-front.ts` makes on its native
305
+ * `app_mention` forward, through the same predicate.
306
+ */
307
+ export async function liveTeamsExternalGate(a, probeFor) {
308
+ const isDm = a.scope === "personal" || a.scope === "groupChat";
309
+ let auth;
310
+ let partition;
311
+ let roomKey;
312
+ try {
313
+ auth = authForActivity(a);
314
+ partition = resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
315
+ // A DM has no parent container to widen to, so it is keyed on its own surface key — the same
316
+ // split liveTeamsDmDivert makes.
317
+ roomKey = isDm ? partition.surfaceKey : domainKeyOf(partition);
318
+ }
319
+ catch {
320
+ return false;
321
+ }
322
+ const policy = teamsExternalPolicyForRoom(roomKey);
323
+ // OPE-493: resolve the SURFACE's own refutation, but only when a grain could act on it. Folded in
324
+ // BEFORE the trust read below, so an opted-in deployment sees the demoted tier as well.
325
+ if (!externalPolicyIsInert(policy))
326
+ auth = await teamsAuthWithUserType(auth, a, probeFor);
327
+ const trust = resolveWakeTrustFromContext({ channel: { kind: "teams" }, session: { auth: { current: auth } } }, { id: a.from.id, isBot: false }, { principalFallback: true });
328
+ // EXPLICIT ADDRESSING — one fact, read twice: the verdict owes a private notice on it, and the
329
+ // OPE-885 ack keys on it. A personal chat is addressing by construction; `isBotMentioned` is
330
+ // Teams' own mention signal, which a NAME-DROP never sets.
331
+ const addressed = a.isBotMentioned === true || a.scope === "personal";
332
+ const verdict = teamsExternalVerdict({
333
+ policy,
334
+ // The SAME per-message facts the taint author leg reads: the resolved human tier, the SURFACE's
335
+ // roster refutation, and the OPERATOR's register question.
336
+ author: { trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) },
337
+ addressed,
338
+ isDm,
339
+ vendorExternal: teamsVendorExternal(a),
340
+ roomKey,
341
+ ...teamsRoomGroupKey(auth),
342
+ });
343
+ if (verdict === "proceed")
344
+ return false;
345
+ // The private notice and the once-ever room announcement both ANSWER the person who addressed us,
346
+ // and both say more than the ack does — so either suppresses it.
347
+ let alreadyAnswered = false;
348
+ if (verdict === "withheld-addressed")
349
+ alreadyAnswered = await postTeamsExternalSenderNotice(a).catch(() => false);
350
+ if (verdict === "silent-room")
351
+ alreadyAnswered = await announceTeamsSilentRoom(a, partition.org, roomKey).catch(() => false);
352
+ // THE MENTION ACK (OPE-885): an explicit @mention or personal-chat message that got no private
353
+ // answer gets the one fixed host line, in the conversation it was said in. An AMBIENT message
354
+ // stays fully dark — `addressed` is false for it, and this is the only gate.
355
+ const acked = owesSuppressedMentionAck({ addressed, alreadyAnswered }) && (await postTeamsNotice(a, EXTERNAL_MENTION_ACK).catch(() => false));
356
+ await appendAudit({
357
+ org: partition.org || deploymentOrg(),
358
+ platform: "teams",
359
+ workspace: partition.workspace,
360
+ surface: roomKey,
361
+ principalId: a.from.id,
362
+ action: "teams.external_policy_suppressed",
363
+ detail: { verdict, conversationId: a.conversation.id, ...suppressionRendering(acked) },
364
+ }).catch(() => undefined);
365
+ tlog(`suppressed ${roomKey}: externalPolicy verdict "${verdict}"`);
366
+ return true;
367
+ }
368
+ /**
369
+ * Resolve the LIVE single-window plan for one verified message activity, fail-closed: any
370
+ * config/partition error, the gates off, the privacy floor failing, or a missing reply target
371
+ * all yield null — the front then FORWARDS to the native channel, byte-identical to today.
372
+ *
373
+ * The GRAIN + VOUCH are keyed on the DOMAIN-GRAIN key (`teams/<tenant>/<channel>` — a Teams
374
+ * conversation id names one REPLY CHAIN, so the listable/vouchable container is the channel;
375
+ * memory/partition.ts's domainKeyOf resolves it via the binding's domainSurface, mirroring the
376
+ * Discord thread→parent-channel precedent). Operators list/vouch `teams/<tenant>` or
377
+ * `teams/<tenant>/<channel-id>` (sanitised segments, same as every stored key).
378
+ */
379
+ export function liveTeamsFrontPlan(a) {
380
+ try {
381
+ const auth = authForActivity(a);
382
+ const partition = resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
383
+ const surfaceKey = domainKeyOf(partition);
384
+ const grain = supervisorGrainFor(surfaceKey, getConfig().loadDomains());
385
+ if (!grainUsesUnifiedWindow(grain))
386
+ return null; // cross-surface OR solo; common path off-window (byte-identical flag-off)
387
+ const tenantId = a.tenantId ?? a.conversation.tenantId;
388
+ // The TEAM rung, derived exactly as identity/binding.ts derives it (see the `vouched` note
389
+ // below). Undefined on a personal or group chat, and on a malformed team id — `teamsTrustGroup`
390
+ // validates the charset and `trustGroupKeyFor` validates the `<marker>:<id>` shape, both
391
+ // fail-closed to no rung.
392
+ const groupKey = trustGroupKeyFor(partition.workspaceKey, teamsTrustGroup(a.teamId)?.trustGroup);
393
+ const facts = {
394
+ scope: a.scope,
395
+ membershipType: teamsMembershipType(a.channelData),
396
+ tenantId,
397
+ // The operator's vouch. `isVouchedSurface` WALKS THE DECLARATION RUNGS (OPE-1521), so a
398
+ // tenant-grain `teams/<tenant>` declaration vouches every room under it, not just the channel
399
+ // key — and `agent/identity/surfaces.ts` ships such a tenant line. A tenant declaration can't
400
+ // tell a native internal channel from a Teams CONNECT / shared channel spanning other
401
+ // tenants, so a shared room whose inbound activity OMITS membershipType passes this floor on
402
+ // that line. The grain check above already limited us to rooms the domain config enrolls — the
403
+ // vouch admits a member, it never enrolls one — but that is no second bound: a container entry
404
+ // claim is legal at tenant grain, and one beside a tenant declaration enrolls the whole
405
+ // tenant. That is the ruling: a declaration vouches, only a POSITIVE external signal
406
+ // demotes. Declare the channel rung to narrow the reach. OE_TRUST=internal is still the
407
+ // deploy-wide blanket switch, and a shared channel is separately refused by the
408
+ // membershipType guard when the signal IS present.
409
+ //
410
+ // 🔴 EVERY RUNG THE BINDING READS IS PASSED HERE TOO (OPE-1538), and that is the whole point:
411
+ // this floor used to be handed the CHANNEL key alone, so two tiers were invisible to it and
412
+ // both misses were fail-open. A `public` `teams/<tenant>/team:<id>` team carve-out and a
413
+ // `public` declaration on ONE reply chain each demoted on the binding and memory planes
414
+ // (identity/binding.ts passes the conversation key, the channel key and the team rung) while
415
+ // this floor saw only the tenant entry and admitted the room — at the one gate where admitting
416
+ // means joining a private cross-surface window. The three keys below are the same three, in
417
+ // the same preference order, derived through the same helpers: `partition.surfaceKey` is the
418
+ // reply chain, `surfaceKey` is `domainKeyOf`'s channel, and `trustGroupKeyFor` +
419
+ // `teamsTrustGroup` build the team rung exactly as the mapper does. One spelling per rung,
420
+ // across both planes. The team rung is absent on a personal or group chat, which carry no team
421
+ // id and are refused by the scope check anyway.
422
+ vouched: isVouchedSurface(partition.surfaceKey, { containerKey: surfaceKey, ...(groupKey === undefined ? {} : { groupKey }) }) ||
423
+ process.env.OE_TRUST === "internal",
424
+ };
425
+ const window = planTeamsSingleWindow(true, a.isBotMentioned, facts, {
426
+ serviceUrl: a.serviceUrl,
427
+ conversationId: a.conversation.id,
428
+ replyToActivityId: teamsThreadRootActivityId(a) ?? undefined,
429
+ tenantId,
430
+ // THE FRONT-DOOR RECEIPT (OPE-1716) — a Bot-Framework activity carries no parsed timestamp on
431
+ // eve's shape (the same fact the trigger's `sentAt` records below), so the ingress row records
432
+ // our own instant and marks it `receipt`, which the split reporter renders as a LOWER BOUND.
433
+ // 🔴 Taken HERE, at the plan, not at the route's first byte — so on Teams the vendor→ingress
434
+ // segment is a floor near zero and says nothing about the vendor's own hop.
435
+ ...spreadVendorTs(receiptEventTime(Date.now())),
436
+ });
437
+ if (!window)
438
+ return null;
439
+ return { window, surfaceKey, partition, grain, auth };
440
+ }
441
+ catch {
442
+ return null; // fail-closed → the native path keeps ownership
443
+ }
444
+ }
445
+ // --- the live dispatch (session deps + the diversion) -----------------------------------------
446
+ /** Live supervisor deps over the (org, grain.registryKey) channel_session registry — the exact
447
+ * notion.ts/linear.ts shape. The wake ALWAYS diverts (teams-supervisor requires the window). */
448
+ /**
449
+ * EXPORTED FOR THE INGRESS CONTRACT TEST (OPE-1719), not for another caller. `liveTeamsDispatch` is
450
+ * the only production caller and it is thirty lines below. `scripts/owned-thread-ingress-wiring-test.ts`
451
+ * drives the `wake` member of THIS object — the real Teams ingress divert — because a test that
452
+ * reimplemented the wake dep would stay green after somebody moved this leg back to the raw divert,
453
+ * which is the exact regression the ticket closes.
454
+ */
455
+ export function liveTeamsSupervisorDeps(plan, to) {
456
+ const org = plan.partition.org;
457
+ const store = getOperationalStore();
458
+ const grain = plan.grain;
459
+ return {
460
+ async getOrCreateSession(_surfaceKey, channelId, atIso) {
461
+ // OPE-599: claim the runtime-change rotation BEFORE the row read, and unconditionally (the
462
+ // mint arm must consume it too). Why, and why here: channels/runtime-rotation.ts.
463
+ const runtimeChanged = await claimRuntimeRotation({ org, registryKey: grain.registryKey, surfaceKey: plan.surfaceKey, domainName: grain.domainName, atIso });
464
+ const existing = await store.getChannelSession(org, grain.registryKey);
465
+ if (!existing) {
466
+ const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, 1, () => teamsSupervisorToken(channelId, 1)), atIso);
467
+ // OPE-1821: stamp the pair onto the row this build just minted. Without it the seat's next
468
+ // wake reads NULL, takes the ADOPTED arm and never rotates for the deploy it crossed.
469
+ await stampMintedRuntime({ org, registryKey: grain.registryKey, surfaceKey: plan.surfaceKey, domainName: grain.domainName, atIso });
470
+ return { token: row.token, generation: row.generation, freshGeneration: true, tail: [] };
471
+ }
472
+ if (shouldRotate(existing, atIso, { runtimeChanged })) {
473
+ const gen = existing.generation + 1;
474
+ // OPE-794: read the OUTGOING generation's recorded tail BEFORE minting its successor. On
475
+ // Teams this is the ONLY continuity there is — the surface exposes no history read.
476
+ const tail = await carryTailForward(org, grain.registryKey, existing.token);
477
+ const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, gen, () => teamsSupervisorToken(channelId, gen)), atIso);
478
+ return { token: row.token, generation: row.generation, freshGeneration: true, tail };
479
+ }
480
+ // A resume whose first wake never delivered (first_wake_at NULL) is still "fresh" (H7c). Its
481
+ // tail is empty — carried and cleared at the rotation that minted this row.
482
+ // `lastWakeAt` rides out on THIS arm alone: it is the catch-up read's lower bound, and a fresh
483
+ // or just-rotated generation has none (rotation resets the column).
484
+ return {
485
+ token: existing.token,
486
+ generation: existing.generation,
487
+ freshGeneration: existing.firstWakeAt == null,
488
+ tail: [],
489
+ lastWakeAt: existing.lastWakeAt ?? null,
490
+ };
491
+ },
492
+ touchSession: (_surfaceKey, atIso) => store.touchChannelSession(org, grain.registryKey, atIso),
493
+ // THE SINCE-LAST-WAKE CATCH-UP (OPE-1726), through the shared path core holds. Teams' own fetch
494
+ // is registered at bundle registration and reads over Microsoft Graph, not the Bot Connector this
495
+ // wake arrived on. The admission policy is the DOMAIN's, resolved by the shared road itself from
496
+ // the request's own channel key (OPE-1857). No `trustFor`: this surface has no synchronous
497
+ // roster probe for a channel author at wake time, so the shared core's FAIL-CLOSED per-sender
498
+ // default applies.
499
+ catchUp: (req) => seatCatchUp("teams", req),
500
+ // OPE-794 — file this wake on the live generation's tail ring. Same key derivation as the
501
+ // rotation read and the spend counter, so all three target one row.
502
+ recordWake: (entry, token) => recordWakeTail(org, grain.registryKey, token, entry),
503
+ now: () => new Date().toISOString(),
504
+ async wake({ token, auth, message, context, window }) {
505
+ // SINGLE-WINDOW diversion (channels/unified-window.ts): the wake goes through eve's
506
+ // cross-channel `to(...).send(...)` into the synthetic `unified` channel, so its effective token is
507
+ // `unified:<raw>`, shared with the domain's Slack/GitHub/Linear/Notion wakes — ONE context
508
+ // window. The unified channel's receive registers the wakeId→origin map entry (durably,
509
+ // BEFORE the send) and re-checks the gates fail-closed. A failure propagates to the FIFO's
510
+ // loud catch (fail-closed: never delivered without a routable reply target).
511
+ //
512
+ // THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719) — the INBOUND variant, the one Slack's front
513
+ // route has always used. A wake landing in a reply chain a live task worker or thread seat
514
+ // OWNS is dispatched to that owner's token instead of the standing seat's. On Teams the reply
515
+ // chain IS the thread (`replyToActivityId`), so a plain conversation send stays top-level and
516
+ // never consults ownership. Flag-off, and on every unowned chain, this is a byte-identical
517
+ // passthrough to `divertToUnifiedWindow`.
518
+ await divertInboundToUnifiedWindow(to, {
519
+ message,
520
+ ...(context.length > 0 ? { context } : {}),
521
+ token,
522
+ window,
523
+ surfaceKey: plan.surfaceKey,
524
+ auth: auth,
525
+ });
526
+ },
527
+ };
528
+ }
529
+ const OK = () => new Response("ok");
530
+ // Durable half of the retry dedupe (Bot Framework redelivers on non-200): mirrors slack-front's
531
+ // liveMarkEventSeen — FAIL-OPEN by design (dedupe prevents a double reply, not a security hole).
532
+ const DEDUPE_TTL_MS = 10 * 60_000;
533
+ let warnedDedupeStore = false;
534
+ async function liveMarkEventSeen(org, key, atIso) {
535
+ try {
536
+ return await getOperationalStore().markEventSeen(org, key, atIso, DEDUPE_TTL_MS);
537
+ }
538
+ catch (err) {
539
+ if (!warnedDedupeStore) {
540
+ warnedDedupeStore = true;
541
+ console.warn(`[open-everywhere:teams-front] durable dedupe unavailable — in-memory only: ${String(err)}`);
542
+ }
543
+ return true;
544
+ }
545
+ }
546
+ /** A swallowed FIFO wake failure, made durable (mirrors slack-front's recordWakeFailure posture:
547
+ * the console line rolls in ~1h; the audit row is the queryable signal). Never throws. */
548
+ function recordTeamsWakeFailure(key, err) {
549
+ void appendAudit({
550
+ org: deploymentOrg(),
551
+ platform: "teams",
552
+ workspace: key.split("/")[1] ?? "unknown",
553
+ surface: key,
554
+ action: "surface.wake_failed",
555
+ detail: { fifoKey: key, message: String(err instanceof Error ? err.message : err).slice(0, 500) },
556
+ }).catch(() => undefined);
557
+ }
558
+ /** The live dispatch for one owned activity: per-message trust off the SAME auth capture the
559
+ * session binds with, then the teams-supervisor core over the live registry deps.
560
+ *
561
+ * `probeFor` is the roster test seam — see {@link teamsAuthWithUserType}. */
562
+ export async function liveTeamsDispatch(a, plan, to, probeFor) {
563
+ // OPE-493: the roster verdict, resolved ONCE and folded into the capture that everything below
564
+ // reads — the per-message trust, the author-refutation leg and the auth the session binds with.
565
+ // Fail-open: no verdict leaves `auth` byte-identical to `plan.auth`.
566
+ const auth = await teamsAuthWithUserType(plan.auth, a, probeFor);
567
+ const wakeCtx = { channel: { kind: "teams" }, session: { auth: { current: auth } } };
568
+ // isBot:false is safe here: the front's author gate already dropped self/bot-authored
569
+ // activities, so a dispatched trigger is a positively-human author (the Slack analog drops
570
+ // authorIsBot in shouldSupervisorWake).
571
+ const trust = resolveWakeTrustFromContext(wakeCtx, { id: a.from.id, isBot: false }, { principalFallback: true });
572
+ let engagementTier;
573
+ try {
574
+ engagementTier = engagementTierFor(plan.surfaceKey, getConfig().loadDomains());
575
+ }
576
+ catch {
577
+ engagementTier = undefined; // fail-soft to neutral, same as every other surface
578
+ }
579
+ // 🔴 THE GRAPH TEAM ID, NOT THE ACTIVITY'S OWN (OPE-1808). `a.teamId` is the BOT CONNECTOR's team
580
+ // id (`19:…@thread.tacv2`); every Graph URL the catch-up builds takes the team's Microsoft 365
581
+ // GROUP id instead. Resolved through the Connector and cached per team — see
582
+ // channels/teams-graph-team-id.ts. Unresolvable ⇒ absent, and the dispatch then STATES that it
583
+ // cannot read this surface's history rather than reading an empty window.
584
+ const graphTeamId = await teamsGraphTeamId(a.teamId, a.serviceUrl);
585
+ const replyChainRootId = teamsReplyChainRoot(a.conversation.id);
586
+ // 🔴 ONE EXPRESSION, READ TWICE (OPE-1747): the taint join below and the dispatch's `ingested`
587
+ // carriage, which is what the TAIL ROW records. Re-deriving it at the second reader would be a
588
+ // second opinion that can disagree with the first about one author.
589
+ const triggerFacts = { trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) };
590
+ await handleTeamsSingleWindowDispatch({
591
+ surfaceKey: plan.surfaceKey,
592
+ // OPE-1528 — the wake row's `container` is the CONVERSATION grain, the same key the post that
593
+ // answers it files under (`partition.surfaceKey` → session-tail-record.ts `tailWindowFor`).
594
+ // `plan.surfaceKey` is the DOMAIN grain and belongs to the gate, the registry row and the
595
+ // audit surface, never to the ring.
596
+ containerKey: plan.partition.surfaceKey,
597
+ // 🔴 THE CHANNEL GRAIN, NEVER THE REPLY CHAIN (OPE-1817). Graph addresses a history read as
598
+ // `/teams/{team}/channels/{channel}`, and a CHANNEL activity's `conversation.id` carries
599
+ // `;messageid=<root>` — a reply-chain id Graph has no channel by that name for, so the
600
+ // unstripped fallback was a 404 the catch-up then read as an empty room. `teamsChannelId`
601
+ // (`channelData.channel.id`) is already at channel grain; the fallback is now stripped to it.
602
+ channelId: a.teamsChannelId ?? teamsChannelGrainConversationId(a.conversation.id),
603
+ // The TEAM the channel sits in, in GRAPH's own spelling — present only on a CHANNEL activity
604
+ // whose Connector team id resolved, and a channel activity is the only shape that has a
605
+ // Graph-readable history at all. Absent ⇒ the catch-up is skipped, never guessed.
606
+ ...(graphTeamId ? { teamId: graphTeamId } : {}),
607
+ // The activity NAMED a team and its Graph id would not resolve — a transient bound on this
608
+ // wake, which the dispatch states differently from a chat's structural one.
609
+ ...(!graphTeamId && a.teamId ? { teamAddressUnresolved: true } : {}),
610
+ // OPE-466 CARRIAGE, over the TRIGGER ONLY (this leg carries no per-message-trust backscroll).
611
+ // OPE-489: the author leg reads the POSITIVE refutation — a resolved `userRole: "guest"`.
612
+ //
613
+ // OPE-493: THE FRONT NOW RUNS THE PROBE. `teamsAuthWithUserType` above resolved the Bot
614
+ // Connector roster verdict and folded it into `auth`, so the SURFACE refutation answers on this
615
+ // path exactly as it does on the native one. The roster probe IS live — a real B2B guest returns
616
+ // `userRole: "guest"` and a tenant member returns `"user"` (measured 2026-08-15). An
617
+ // unresolved verdict is still absent, and this leg fails OPEN on absence.
618
+ //
619
+ // OPE-516/OPE-525: the OPERATOR's register (`teamsAuthorFacts`) is the SECOND source of
620
+ // refutation, and answers where the roster cannot (a guest the connector will not report, a
621
+ // tenant whose bot credential cannot read the roster). Unarmed ⇒ it contributes nothing.
622
+ auth: stampNonMemberChatAuth(auth, [triggerFacts], {
623
+ surfaceKey: plan.surfaceKey,
624
+ ...teamsRoomGroupKey(auth),
625
+ }),
626
+ // THE SAME ONE ENTRY the join above reads — carried so the dispatch's tail row records it.
627
+ ingested: [triggerFacts],
628
+ trigger: {
629
+ id: a.id,
630
+ authorId: a.from.id,
631
+ authorName: a.from.name,
632
+ // Bot-Framework activities carry no parsed timestamp on eve's shape — the arrival time
633
+ // is the honest stamp (the same "now" the envelope's current-time uses).
634
+ sentAt: new Date().toISOString(),
635
+ text: a.text || "(the message had no text)",
636
+ trust,
637
+ },
638
+ // Thread meta ⇔ the trigger sat inside an EXISTING reply chain (its replyToId); a
639
+ // top-level channel post starts its own chain and renders container-scoped, mirroring
640
+ // Slack's threadTs-presence semantics.
641
+ ...(a.replyToId ? { threadRootId: teamsThreadRootActivityId(a) ?? a.replyToId } : {}),
642
+ // 🔴 THE READ'S OWN ROOT, AND IT IS NOT `threadRootId` (OPE-1817). The two answer different
643
+ // questions and only one of them is about Graph:
644
+ // • `threadRootId` is the ENVELOPE's thread meta. It is deliberately absent on a top-level
645
+ // post, because that post renders container-scoped — Slack's semantics, kept.
646
+ // • this one is the CONTAINER THE SEAT IS BOUND TO. `plan.partition.surfaceKey` is the
647
+ // CONVERSATION grain (`bindTeams`'s `surface`), and on a channel activity that is always
648
+ // one reply chain, named by the `;messageid=<root>` suffix — present on a top-level post
649
+ // too, where it is that post's own id.
650
+ // The catch-up must read THAT chain TOO. `/channels/{c}/messages` returns the channel's
651
+ // top-level messages, which by Graph's contract carry no replies at all, so a chain that did
652
+ // carry the message came back empty (ship #206/#207). `wakeAddress` in `teams-read.ts`
653
+ // already resolves the root this way for `read_thread`; this is the same resolution, brought
654
+ // to the read the seat's own wake depends on.
655
+ ...(replyChainRootId ? { replyChainRootId } : {}),
656
+ engagementTier,
657
+ window: plan.window,
658
+ }, liveTeamsSupervisorDeps(plan, to));
659
+ await appendAudit({
660
+ org: plan.partition.org,
661
+ platform: "teams",
662
+ workspace: plan.partition.workspace,
663
+ surface: plan.surfaceKey,
664
+ principalId: a.from.id,
665
+ action: "teams.dispatched",
666
+ detail: { addressing: plan.window.addressing ?? "channel-watch", conversationId: a.conversation.id },
667
+ }).catch(() => undefined);
668
+ }
669
+ /**
670
+ * THE DM WINDOW DIVERT (OPE-357) — a Teams personal/group chat lands in its OWN first-class standing
671
+ * window (channels/dm-seat.ts), the Teams parity of divertDiscordDmToWindow / slack-front's
672
+ * scheduleDmWake, and the last DM-family surface to first-class (Slack: OPE-351 stages 2+4; Discord:
673
+ * OPE-356).
674
+ *
675
+ * WHAT IT FIXES. Until now a Teams DM ran on eve's native per-conversation token (teams.ts's
676
+ * onMessage): a session with no window row, and therefore NO DISPATCHABLE ADDRESS. A DM that delegated
677
+ * could not be told the answer had arrived — `resolveCallerSeat` took the OPE-306 ANCHOR arm and the
678
+ * worker posted its own report into the DM (the OPE-358 double-post). A window token IS an address, so
679
+ * the DM seat now gets the whole standing-seat apparatus for free: woken, redriven, rotated (on the DM
680
+ * thresholds — `OE_DM_SEAT_ROTATE_*`, never the supervisor's), spend-counted and audited through the
681
+ * seams every other seat already uses. The worker's direct report-back stops the moment the seat
682
+ * becomes a real window (its caller now resolves via `ownWindowSeat`, not the anchor) — no two voices
683
+ * in one DM per delegated turn.
684
+ *
685
+ * SEPARATE ADMIT SPACE, NOT A FLOOR BYPASS. This never touches `teamsSingleWindowPrivacyOk`: that floor
686
+ * refuses `scope !== "channel"` outright because a personal/group chat may never join the SHARED
687
+ * cross-surface window, and it stays exactly that strict. A DM window is a STRUCTURALLY DIFFERENT,
688
+ * non-shared address space keyed on the DM's own surface key (`getOrCreateDmSeat`), deliverable to its
689
+ * own DM and nowhere else — which is precisely why it is safe to admit a DM here without weakening the
690
+ * channel floor (the Discord `isDm`-before-the-single-window-block precedent).
691
+ *
692
+ * 🔴 IT BYPASSES THE ENGAGEMENT GATE — a DM is always answered (see {@link buildTeamsDmWake}). The
693
+ * SPEND gate is NOT bypassed: `divertToUnifiedWindow` runs its own window-door gate (channels/unified.ts
694
+ * windowSpendGate), which is exactly the choke point every Teams single-window divert already leans on
695
+ * (Teams has never front-positioned a pre-gate, unlike Discord).
696
+ *
697
+ * FAIL-CLOSED: a seat-resolution or dispatch failure PROPAGATES to the FIFO's loud catch. There is
698
+ * deliberately NO fall-back to the native send — that would answer the human from a second session with
699
+ * no window row behind it, the very topology this replaces (and the OPE-351 stranding hazard).
700
+ */
701
+ export async function liveTeamsDmDivert(a, capturedAuth, partition, to, probeFor) {
702
+ // OPE-493: the roster verdict for the DM PARTNER, folded into the capture before anything reads it.
703
+ // A Teams DM used to bind `internal` unconditionally (teamsTrust's personal-silo branch), so a B2B
704
+ // guest DMing the bot lit the `dm` shared memory layer. A resolved guest now demotes it to
705
+ // `external`, which is what the Slack DM-partner probe has always done. Fail-open: no verdict
706
+ // leaves the capture byte-identical.
707
+ const auth = await teamsAuthWithUserType(capturedAuth, a, probeFor);
708
+ const org = partition.org;
709
+ const atIso = new Date().toISOString();
710
+ // A DM has no parent container to widen to, so the seat is keyed on the DM's OWN surface key —
711
+ // NOT domainKeyOf (which resolves a channel's parent). For a personal/group chat the mapper leaves
712
+ // domainSurface unset, so partition.surfaceKey already IS the conversation-grain key.
713
+ const surfaceKey = partition.surfaceKey;
714
+ const conversationId = a.conversation.id;
715
+ // The seat: create / rotate / resume the DM's own standing window row.
716
+ const seat = await getOrCreateDmSeat(org, { surfaceKey }, atIso);
717
+ // PER-WAKE TRUST off the SAME auth capture the session binds with; `principalFallback` because a DM
718
+ // partner is the seat's principal. `isBot:false` is safe — the front's author gate already dropped
719
+ // self/bot-authored activities before this runs.
720
+ const wakeCtx = {
721
+ channel: { kind: "teams" },
722
+ session: { auth: { current: auth } },
723
+ };
724
+ const trust = resolveWakeTrustFromContext(wakeCtx, { id: a.from.id, isBot: false }, { principalFallback: true });
725
+ const wakeId = mintWakeId();
726
+ // The frozen reply target: a PLAIN conversation send into the DM — no `replyToActivityId` (a personal
727
+ // chat has no reply chain; a group chat's chain anchor is not a divert fact) — the shape
728
+ // teamsPostArm.dispatchUnified already posts back through (serviceUrl + conversationId, no reply
729
+ // anchor ⇒ plain send). This IS the mint now: the codec's `dmOrigin` arm that used to produce the
730
+ // same minimal origin for a DM delegation anchor went with the anchor (OPE-368).
731
+ const origin = { platform: "teams", serviceUrl: a.serviceUrl, conversationId };
732
+ const rendered = renderWake(buildTeamsDmWake({
733
+ conversationId,
734
+ wakeId,
735
+ ...(seat.freshGeneration && seat.tail.length > 0 ? { tail: seat.tail } : {}),
736
+ trigger: {
737
+ id: a.id || "unknown",
738
+ author: a.from.name ?? a.from.id,
739
+ authorId: a.from.id,
740
+ sentAt: atIso,
741
+ text: a.text || "(the message had no text)",
742
+ trust,
743
+ },
744
+ }));
745
+ await divertToUnifiedWindow(to, {
746
+ message: rendered.message,
747
+ ...(rendered.context && rendered.context.length > 0 ? { context: rendered.context } : {}),
748
+ token: seat.token,
749
+ // `vendorTs` (OPE-1716): the DM leg's own FRONT-DOOR RECEIPT, for the same reason and with the
750
+ // same limits as the channel leg's — a Bot-Framework activity carries no parsed timestamp. A DM
751
+ // wake without it would leave its ingress row with no first anchor at all.
752
+ window: { wakeId, origin, addressing: "direct-mention", ...spreadVendorTs(receiptEventTime(Date.now())) },
753
+ // Byte-exactly the surface named inside the token — the self-consistency both admission gates match
754
+ // on, and the whole privacy fence (a DM window is deliverable to ITS OWN DM and nowhere else).
755
+ surfaceKey,
756
+ // OPE-466 CARRIAGE: one ingested message, so the join runs over the trigger's own tier.
757
+ // OPE-489: the author leg reads a positively-resolved `userRole: "guest"`, which the roster probe
758
+ // above put on this capture (OPE-493); a DM has no room leg (`dm: true` — on a DM the trust plane
759
+ // asks about the PARTNER, not about the room).
760
+ auth: stampNonMemberChatAuth(auth, [{ trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) }], { dm: true }),
761
+ });
762
+ // Record the wake on the seat's own row (turnCount/lastWakeAt feed the rotation thresholds),
763
+ // best-effort — a touch failure must never affect the reply.
764
+ await touchDmSeat(org, surfaceKey, atIso).catch(() => undefined);
765
+ // OPE-794 — file this DM wake on the live seat generation's tail ring, AFTER it landed, and only
766
+ // the TRIGGER. The registry key is the DM window key, the same row the seat itself lives in.
767
+ await recordWakeTail(org, dmWindowRegistryKey(surfaceKey), seat.token, {
768
+ kind: "wake",
769
+ at: atIso,
770
+ from: "human", // the front's author gate already dropped self/bot-authored activities
771
+ trust,
772
+ // THE AUTHOR GRAIN (OPE-1747) — the same triple the DM's `stampNonMemberChatAuth` join above
773
+ // reads: the resolved tier, Entra's `userType: "guest"` refutation, and the operator's register.
774
+ ...tailAuthorGrain({ trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) }),
775
+ author: a.from.name ?? a.from.id,
776
+ authorId: a.from.id,
777
+ id: a.id || "unknown",
778
+ platform: "teams",
779
+ container: surfaceKey,
780
+ text: a.text ?? "",
781
+ });
782
+ await appendAudit({
783
+ org,
784
+ platform: "teams",
785
+ workspace: partition.workspace,
786
+ surface: surfaceKey,
787
+ principalId: a.from.id,
788
+ action: "teams.dispatched",
789
+ detail: { addressing: "direct-mention", dmWindow: true, conversationId },
790
+ }).catch(() => undefined);
791
+ }
792
+ /**
793
+ * Build the front route handler. Verifies (fail-closed 401), classifies, then forwards or
794
+ * diverts. `innerHandler` is teamsChannel's own POST handler, invoked with a FRESH Request
795
+ * (re-readable body) + the same {@link RouteHandlerArgs}, so teamsChannel re-verifies and
796
+ * dispatches exactly as if it owned the URL — byte-identical whenever no plan resolves.
797
+ */
798
+ export function buildTeamsFrontHandler(innerHandler, opts = {}) {
799
+ // TODO(teams-front-credentials): the default verify uses env app id only (`{}`). If the wrapped
800
+ // native teamsChannel is ever built with `credentials.appId` or a custom webhookVerifier, the
801
+ // front would verify with a DIFFERENT config than the inner re-verify (front could 401 a request
802
+ // the inner would accept, or vice-versa). Harmless today (teams.ts passes no credentials, so both
803
+ // sides use the env app id), but if teams.ts starts passing credentials, thread them here via
804
+ // opts.verify. Needs eve's Channel to expose the inner verifier config to plumb cleanly.
805
+ const verify = opts.verify ?? ((req) => verifyTeamsRequest(req, {}));
806
+ const dedupe = opts.dedupe ?? new EventDedupe();
807
+ const fifo = opts.fifo ?? new ThreadFifo(recordTeamsWakeFailure);
808
+ const now = opts.now ?? (() => Date.now());
809
+ const markEventSeen = opts.markEventSeen ?? liveMarkEventSeen;
810
+ const planFor = opts.planFor ?? liveTeamsFrontPlan;
811
+ const dispatch = opts.dispatch ?? liveTeamsDispatch;
812
+ const dmDivert = opts.dmDivert ?? liveTeamsDmDivert;
813
+ const externalGate = opts.externalGate ?? liveTeamsExternalGate;
814
+ const stopReaction = opts.stopReaction ?? ((rx, attachSession) => routeTeamsStopReaction(rx, { attachSession }));
815
+ const engagementSeam = opts.engagementSeam ?? resolveTeamsEngagement;
816
+ const markEngaged = opts.markEngaged ?? ((a) => markTeamsEngaged(a));
817
+ const roomStopped = opts.roomStopped ?? teamsRoomWatchStopped;
818
+ const markRoomWatched = opts.markRoomWatched ?? markTeamsRoomWatched;
819
+ const clearRoomStop = opts.clearRoomStop ?? clearTeamsRoomStop;
820
+ return async (req, args) => {
821
+ let rawBody;
822
+ try {
823
+ rawBody = await verify(req);
824
+ }
825
+ catch {
826
+ return new Response("unauthorized", { status: 401 });
827
+ }
828
+ const forward = () => {
829
+ const forwarded = new Request(req.url, { method: "POST", headers: req.headers, body: rawBody });
830
+ return innerHandler(forwarded, args);
831
+ };
832
+ let parsed;
833
+ try {
834
+ parsed = JSON.parse(rawBody);
835
+ }
836
+ catch {
837
+ return forward(); // unparseable — let teamsChannel ack it exactly as today
838
+ }
839
+ // ── THE STOP REACTION (OPE-898) ──────────────────────────────────────────────────────────────
840
+ //
841
+ // POSITION IS LOAD-BEARING. It sits ABOVE `parseTeamsActivity`, because that parser answers only
842
+ // for `message` activities — a `messageReaction` comes back as null and would fall straight into
843
+ // the forward below, which is why this affordance did nothing before. It is also the whole point
844
+ // that this runs in the INGRESS: `cancelAnchoredTurn` mints no session and calls no model, so a
845
+ // stop stays free and keeps working for a scope that is already over its spend cap.
846
+ //
847
+ // ACK, NEVER FORWARD. The native channel has nothing to do with a reaction activity, and the arm
848
+ // fails closed on every unknown — an unrecognised body simply is not a reaction and returns null.
849
+ const reaction = parseTeamsReaction(parsed);
850
+ if (reaction) {
851
+ args.waitUntil(stopReaction(reaction, args.attachSession).then((o) => tlog(`reaction in ${reaction.conversationId ?? "?"}: ${o.stopped ? o.outcome : o.reason}`), (err) => tlog(`stop leg failed: ${String(err)}`)));
852
+ return OK();
853
+ }
854
+ const activity = parseTeamsActivity(parsed);
855
+ if (!activity || activity.type !== "message") {
856
+ return forward(); // invokes / conversationUpdates / unknown — the native channel owns them
857
+ }
858
+ // The author gate, resolved ONCE for every branch below: a self/bot-authored activity never
859
+ // wakes anything here. It is also the reason the policy gate below skips them — a machine author
860
+ // is below peer standing but it is not a person outside the organisation (the `relay` ruling in
861
+ // access/external-policy.ts), so it has no author-grain verdict to receive.
862
+ // ONE PREDICATE, SHARED WITH THE SEAM (channels/teams-engagement.ts). The native gate needs the
863
+ // same answer — a machine-authored CHANNEL activity is still forwarded to it — and two spellings
864
+ // of "is this a bot" is how one of them comes to miss a case the other catches.
865
+ const machineAuthored = teamsMachineAuthored(activity);
866
+ // ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain, OPE-504 room grain) ────────────────
867
+ //
868
+ // POSITION IS LOAD-BEARING, and for TWO reasons on Teams. It sits above the DM divert and above
869
+ // `planFor`, so (a) an activity the agent may not act on costs nothing — no seat resolution, no
870
+ // session mint, no spend read — and (b) a SUPPRESSED activity is never FORWARDED, which is what
871
+ // extends the policy to a deployment running the native path with the window gates off.
872
+ //
873
+ // Both grains are inert under their shipped values (`taint` / `engage`), so this is a no-op on
874
+ // every deployment that has not opted in.
875
+ if (!machineAuthored && (await externalGate(activity)))
876
+ return OK();
877
+ // ── CAPTURE THE CONVERSATION REFERENCE (OPE-887) ─────────────────────────────────────────────
878
+ //
879
+ // THE ONE PLACE A PROACTIVE TEAMS ADDRESS EVER COMES FROM. A Bot Framework send needs a regional
880
+ // `serviceUrl` and a Microsoft-minted conversation id, and neither exists anywhere but on an
881
+ // inbound activity — so an activity that passes here without being recorded is an address this
882
+ // deployment can never hold again. Both Teams arms that address a person or a team with no live
883
+ // turn (the DM arm, the account directory) read what this writes.
884
+ //
885
+ // POSITION IS LOAD-BEARING, in both directions. It sits BELOW the external-participant gate, so a
886
+ // suppressed author never contributes an address the agent could later message; and it sits ABOVE
887
+ // the DM divert and `planFor`, so it captures EVERY activity this deployment acts on — including
888
+ // the ones that fall through to the native path when the window gates are off, which no branch
889
+ // below would have covered.
890
+ //
891
+ // AFTER THE ACK, and fail-soft inside: `recordTeamsConversationReference` never throws, and a lost
892
+ // capture costs a future delivery, never this wake. Machine-authored activities record nothing —
893
+ // there is no person or team to reach behind a bot.
894
+ if (!machineAuthored) {
895
+ args.waitUntil(recordTeamsConversationReference({
896
+ scope: activity.scope,
897
+ tenantId: activity.tenantId ?? activity.conversation.tenantId,
898
+ conversationId: activity.conversation.id,
899
+ serviceUrl: activity.serviceUrl,
900
+ userId: activity.from.id,
901
+ teamId: activity.teamId,
902
+ }));
903
+ }
904
+ // ── LEG E: THE AMBIENT ENGAGEMENT SEAM (OPE-1500, contract §2.5) ─────────────────────────────
905
+ //
906
+ // The middle posture, and the one Teams had no form of: an @mention marks the conversation
907
+ // ENGAGED, a later PLAIN message in it wakes the agent, and `stand_down` stores the stop. The
908
+ // policy is core's (`channels/engagement.ts` — `markThreadEngaged` / `shouldDeliverReply` / the
909
+ // durable `stopped` flag); `channels/teams-engagement.ts` translates Teams facts into it.
910
+ //
911
+ // POSITION IS LOAD-BEARING, and it is what keeps LEG A AND LEG E FROM DOUBLE-HANDLING ONE
912
+ // MESSAGE. This block runs ABOVE both the DM divert and `planFor`, and it dispatches NOTHING of
913
+ // its own. It contributes exactly two decisions, and every wake below stays the property of the
914
+ // leg that already owned it:
915
+ // • THE MARK, on a mention, on EVERY path — including a single-window channel, where Leg A
916
+ // answers the mention. Without the mark on that path `stand_down` would have no row to flag
917
+ // and would refuse on a surface the verb is supposed to work on.
918
+ // • THE SUPPRESSION, on a stood-down conversation, ABOVE EVERYTHING — never diverted, never
919
+ // forwarded, so both legs go quiet. This is the half §7.6 of the contract records as
920
+ // missing: Leg A's ambient wake reads no registry of its own.
921
+ //
922
+ // 🔴 WHAT THIS SUPPRESSION COVERS, AND WHERE THE OTHER HALF LIVES. The `stopped` flag read here
923
+ // sits on a CONVERSATION row, and only an @mention writes one — so this block closes exactly the
924
+ // case where somebody invited the agent into a conversation and then asked it to stop. Leg A's
925
+ // UNDIRECTED ambient wake is a room-grain wake with no conversation row behind it, so it has its
926
+ // own room-grain stop further down this handler (OPE-1511, search LEG A'S OWN STOP).
927
+ //
928
+ // THE TWO ARE NOT INDEPENDENT, and the overlap runs one way. A ROOM stop suppresses an ENGAGED
929
+ // conversation's ambient follow-up too: that activity resolves `deliver` here, falls through
930
+ // this block, and is dropped by the room gate below. So a stop asked for in one reply chain
931
+ // silences a chain whose people did invite the agent. It is the honest reading of a room-grain
932
+ // ask — `stand_down`'s room copy says "anywhere in this room" — and it errs toward silence,
933
+ // which is the safe direction for a wake gate. It does NOT run the other way: a CONVERSATION
934
+ // stop leaves the room row alone, so the rest of the room keeps waking.
935
+ //
936
+ // THE DELIVERY HALF IS NOT DECIDED HERE, and deliberately so. The front holds no primitive that
937
+ // could wake an existing native session, so admitting the activity through the NATIVE mention
938
+ // gate IS the wake (channels/teams.ts, which asks the same seam). A `deliver` AND a plain `hold`
939
+ // therefore both fall through to the ordinary forward, and that gate answers. The cost is one
940
+ // extra registry read on a forwarded non-mention message; the alternative — dropping a `hold`
941
+ // here — would skip everything eve's own channel does with an activity before `onMessage`, for
942
+ // no change in outcome. A read is the cheaper mistake to be wrong about.
943
+ //
944
+ // Machine-authored activities skip the seam entirely: they wake nothing on any path, so the
945
+ // registry read would be paid for a message with no decision behind it.
946
+ const seam = machineAuthored ? null : await engagementSeam(activity, machineAuthored);
947
+ if (seam?.kind === "engage") {
948
+ // 🔴 THE MARK IS DEDUPED, AND THE STOP IS WHY. `markThreadEngaged({ participating: true })` is
949
+ // the re-invitation, so it CLEARS a `stopped` flag — correct for a person's second @mention,
950
+ // wrong for the Bot Framework REDELIVERING the first one. Redelivery is routine here (a slow
951
+ // ack is enough, and this mark is itself awaited before the ack). Both layers, exactly as the
952
+ // wake dedupe below uses them, under a SEPARATE key prefix so neither consumes the other's
953
+ // first sighting. Fail-open on a store error, same posture as that dedupe.
954
+ //
955
+ // 🔴 THIS IS THE CHEAP LAYER, NOT THE GUARANTEE (OPE-1527). Both layers EXPIRE — the in-memory
956
+ // one with the process, the durable one after DEDUPE_TTL_MS — and a stand-down does not expire
957
+ // at all, so a redelivery on a fresh process after the TTL sailed through here and lifted the
958
+ // stop. What actually holds is in the store: the promotion carries the activity id as
959
+ // `engagedBy`, and the row refuses to clear `stopped` for an id it already recorded
960
+ // (channels/teams-engagement.ts `markTeamsEngaged`). This layer stays because it saves the
961
+ // round-trip on the common retry, never because it bounds the replay.
962
+ const engageKey = `teams-engage:${activity.conversation.id}:${activity.id}`;
963
+ if (!dedupe.seen(engageKey, now())) {
964
+ dedupe.mark(engageKey, now());
965
+ // AWAITED, not deferred: the mark must land before the next message in this conversation is
966
+ // decided against it, and `markThreadEngaged` already swallows a store failure.
967
+ if (await markEventSeen(seam.org, engageKey, new Date(now()).toISOString()))
968
+ await markEngaged(activity);
969
+ }
970
+ }
971
+ if (seam?.kind === "hold" && seam.stopped) {
972
+ // 🔴 A PARKED QUESTION'S ANSWER IS NEVER SUPPRESSED. eve routes a text HITL reply as an
973
+ // ordinary `message` activity and branches on it BEFORE `onMessage`, so a blanket early return
974
+ // here would silently drop the answer to a question the agent itself asked — the exact
975
+ // silent-HITL-drop shape channels/teams-input-response.ts exists to prevent (OPE-574). Standing
976
+ // down means "stop following the room", never "stop listening to what I asked you".
977
+ if (isTeamsInputResponseActivity(activity)) {
978
+ tlog(`activity ${activity.id} in ${activity.conversation.id}: stood down, but this is an answer to a parked question — forwarded`);
979
+ return forward();
980
+ }
981
+ tlog(`activity ${activity.id} in ${activity.conversation.id}: stood down here — suppressed on BOTH legs until an @mention`);
982
+ return OK();
983
+ }
984
+ // FIRST-CLASS DM WINDOW DIVERT (OPE-357). A personal (1:1) or group chat is diverted into its OWN
985
+ // dedicated DM window — a STRUCTURALLY SEPARATE admit space from the shared channel window. This
986
+ // sits BEFORE `planFor` deliberately: the single-window privacy floor (teamsSingleWindowPrivacyOk)
987
+ // refuses `scope !== "channel"` UNCONDITIONALLY and must stay that strict; a DM window never joins
988
+ // the shared one, so bypassing that floor here is correct, not a weakening (the Discord
989
+ // `isDm`-before-the-single-window-block precedent). It is UNCONDITIONAL since OPE-368: the
990
+ // `OE_DM_SEAT_ROTATION=0` hatch that used to sit in this condition (falling through to planFor's
991
+ // floor, which forwards the DM to the native path) was the last supported way to run a Teams DM on
992
+ // an un-wakeable native token — the seat class the retired OPE-306 delegation anchor served.
993
+ if (activity.scope === "personal" || activity.scope === "groupChat") {
994
+ // Author gate (mirrors the channel branch below): a machine-authored DM activity never wakes a
995
+ // seat — dropped, not forwarded (a DM window owns this surface; the native path is retired here).
996
+ if (machineAuthored)
997
+ return OK();
998
+ // 🔴 LEG E GATES A GROUP CHAT, AND NEVER A 1:1 (OPE-1500). A personal chat resolves
999
+ // `unanchored`, so this line cannot fire on one: "a DM is always answered" is unchanged.
1000
+ //
1001
+ // A GROUP CHAT is different, and this narrows what it used to do. The `ChatMessage.Read.Chat`
1002
+ // RSC grant makes Microsoft deliver EVERY group-chat message here, and this divert is
1003
+ // unconditional (OPE-368) — so a granted tenant ran a full agent session on every message any
1004
+ // member typed. The native mention gate (channels/teams.ts) never covered it: a group chat is
1005
+ // diverted here and never forwarded. The seam is the posture that belongs in between — the
1006
+ // chat is answered once it @mentions the agent, and goes quiet again on `stand_down`.
1007
+ //
1008
+ // FAIL-CLOSED, and that is a real second narrowing: `getThreadEngagement` reads a store failure
1009
+ // as "no row" (channels/engagement.ts), so an operational-store outage holds every group chat
1010
+ // rather than answering it. That is the right direction for a wake gate — an unreadable
1011
+ // registry cannot prove the agent was invited — but it means a chat can go quiet for a reason
1012
+ // nobody in it said out loud.
1013
+ if (seam?.kind === "hold") {
1014
+ // 🔴 THE SAME PARKED-QUESTION CARVE-OUT THE STOOD-DOWN BRANCH CARRIES, and this branch needs it
1015
+ // for a WIDER set of holds: a group chat holds whenever it is un-engaged, which includes the
1016
+ // fail-closed hold a store outage produces. A routine can park a question in a chat that holds
1017
+ // for either reason, and dropping the person's answer here would be the silent-HITL-drop shape
1018
+ // channels/teams-input-response.ts exists to prevent (OPE-574). Forwarded, so eve's own HITL
1019
+ // path answers it.
1020
+ if (isTeamsInputResponseActivity(activity)) {
1021
+ tlog(`activity ${activity.id} in ${activity.conversation.id}: group chat not engaged, but this is an answer to a parked question — forwarded`);
1022
+ return forward();
1023
+ }
1024
+ tlog(`activity ${activity.id} in ${activity.conversation.id}: group chat not engaged — ${seam.reason}`);
1025
+ return OK();
1026
+ }
1027
+ // Two-layer idempotency, same as the channel path: an in-memory guard first (no org needed), then
1028
+ // the durable store once the partition is resolved. A Bot-Framework redelivery must never
1029
+ // double-wake; fail-open on a store error (dedupe prevents a double reply, not a security hole).
1030
+ const dmKey = `teams:${activity.conversation.id}:${activity.id}`;
1031
+ if (dedupe.seen(dmKey, now()))
1032
+ return OK();
1033
+ dedupe.mark(dmKey, now());
1034
+ // Resolve the DM's identity ONCE (auth + partition) — fail-closed to the native path on any
1035
+ // binding/partition error, exactly as liveTeamsFrontPlan's own try/catch does.
1036
+ let dm;
1037
+ try {
1038
+ const auth = authForActivity(activity);
1039
+ const partition = resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
1040
+ dm = { auth, partition };
1041
+ }
1042
+ catch {
1043
+ dm = null;
1044
+ }
1045
+ if (!dm)
1046
+ return forward(); // can't anchor the DM to an org ⇒ let the native path own it
1047
+ const { auth: dmAuth, partition: dmPartition } = dm; // const-capture for the deferred FIFO closure
1048
+ if (!(await markEventSeen(dmPartition.org, dmKey, new Date(now()).toISOString())))
1049
+ return OK();
1050
+ tlog(`activity ${activity.id} on ${dmPartition.surfaceKey}: DM — diverting to its own first-class DM window`);
1051
+ // Per-container FIFO (the same discipline as the channel path): one DM's wakes deliver strictly in
1052
+ // arrival order; a failure lands in the FIFO's loud catch + a durable audit row.
1053
+ args.waitUntil(fifo.enqueue(dmPartition.surfaceKey, () => dmDivert(activity, dmAuth, dmPartition, args.to)));
1054
+ return OK();
1055
+ }
1056
+ // THE one single-window decision (single-leg: one webhook, one synchronous verdict). No plan
1057
+ // — gates off, floor failed, config/partition error — ⇒ forward, byte-identical to today.
1058
+ const plan = planFor(activity);
1059
+ if (!plan)
1060
+ return forward();
1061
+ // Author gate (mirrors Slack's authorIsBot disqualifier on a kept mention): the window owns
1062
+ // this surface's traffic, and machine-authored activities never wake the shared session —
1063
+ // dropped, not forwarded (forwarding would hand a suppressed surface back to the native
1064
+ // session for bots only, splitting the context the window exists to unify).
1065
+ if (machineAuthored) {
1066
+ tlog(`activity ${activity.id} on ${plan.surfaceKey}: bot/self-authored — dropped (single-window surface, relay is Phase 2)`);
1067
+ return OK();
1068
+ }
1069
+ // ── LEG A'S OWN STOP (OPE-1511) ─────────────────────────────────────────────────────────────
1070
+ //
1071
+ // The half the block above could not cover, and now does. Leg A wakes on ANY message in a
1072
+ // vouched single-window channel, including a reply chain nobody ever @mentioned the agent in.
1073
+ // A `stopped` flag lives on an engagement row and only an @mention wrote one, so a person in
1074
+ // such a room could not stop the wake and `stand_down` refused for want of a row. The rule is
1075
+ // that a stored stop exists exactly where a stored wake exists: this leg now stores a ROOM-grain
1076
+ // row on every ambient wake, reads that row's flag here, and `tools/stand_down.ts` marks it when
1077
+ // the conversation grain holds nothing (channels/teams-engagement.ts carries the whole shape).
1078
+ //
1079
+ // AN @MENTION IS THE WAY BACK, and it must run BEFORE the gate or the stop would be a trap: the
1080
+ // mention re-invites the ROOM, and Leg E's own mark re-invites only the conversation. The clear
1081
+ // carries the activity id, so a redelivery of the mention the row records lifts nothing
1082
+ // (OPE-1527; one slot — OPE-1562).
1083
+ // A FAILED clear leaves the room stopped and still answers this mention — the person's next
1084
+ // @mention retries it, which is the safe direction for a write that only ever removes a stop.
1085
+ //
1086
+ // 🔴 NO `.catch()` ON THE GATE, and that asymmetry is deliberate. `teamsRoomWatchStopped` owns
1087
+ // its own failure direction and picks FAIL-CLOSED (hold the wake, its doc says why). A swallow
1088
+ // here would silently invert that to fail-open and hand a stood-down room back to the ambient
1089
+ // leg — the exact defect the read was written to prevent. It never throws.
1090
+ const addressedHere = plan.window.addressing === "direct-mention";
1091
+ if (addressedHere) {
1092
+ await clearRoomStop(plan.partition, new Date(now()).toISOString(), activity.id).catch(() => undefined);
1093
+ }
1094
+ else if (await roomStopped(plan.partition)) {
1095
+ // 🔴 THE SAME PARKED-QUESTION CARVE-OUT the stood-down conversation branch carries. eve routes
1096
+ // a text HITL answer as an ordinary message activity, so dropping one here would silently lose
1097
+ // the answer to a question the agent itself asked (OPE-574).
1098
+ if (isTeamsInputResponseActivity(activity)) {
1099
+ tlog(`activity ${activity.id} on ${plan.surfaceKey}: room stood down, but this is an answer to a parked question — forwarded`);
1100
+ return forward();
1101
+ }
1102
+ tlog(`activity ${activity.id} on ${plan.surfaceKey}: this room was stood down — ambient wake suppressed until an @mention`);
1103
+ return OK();
1104
+ }
1105
+ // Two-layer idempotency (in-memory + the durable cross-instance store): a Bot-Framework
1106
+ // redelivery (non-200 → retry, same activity id) must never double-wake. Fail-open on a
1107
+ // store error — dedupe prevents a double reply, not a security hole.
1108
+ const key = `teams:${activity.conversation.id}:${activity.id}`;
1109
+ if (dedupe.seen(key, now()))
1110
+ return OK();
1111
+ dedupe.mark(key, now());
1112
+ if (!(await markEventSeen(plan.partition.org, key, new Date(now()).toISOString())))
1113
+ return OK();
1114
+ tlog(`activity ${activity.id} on ${plan.surfaceKey}: single-window — diverting to the unified supervisor ` +
1115
+ `(${plan.window.addressing === "direct-mention" ? "addressed @mention; native forward suppressed" : "ambient channel-watch"})`);
1116
+ // THE STORED WAKE THE STORED STOP NEEDS (OPE-1511). Written for the AMBIENT leg only: an
1117
+ // addressed @mention already engages its conversation through Leg E, and it has just re-invited
1118
+ // the room above. Deferred, because it decides nothing about this wake — a lost row costs a
1119
+ // later stop somewhere to land, never a turn.
1120
+ if (!addressedHere)
1121
+ args.waitUntil(markRoomWatched(plan.partition, new Date(now()).toISOString()).catch(() => undefined));
1122
+ // Per-container FIFO (the slack-front supervisor-key discipline): one channel's wakes deliver
1123
+ // strictly in arrival order; a failure lands in the FIFO's loud catch + a durable audit row.
1124
+ args.waitUntil(fifo.enqueue(plan.surfaceKey, () => dispatch(activity, plan, args.to)));
1125
+ return OK();
1126
+ };
1127
+ }
1128
+ /**
1129
+ * Compose the front route with a wrapped `teamsChannel`. `inner` must have been built with
1130
+ * `route: INTERNAL_TEAMS_ROUTE`. Returns the SAME channel value (preserving eve's internal
1131
+ * adapter/receive) with the public front route prepended — so the Bot Framework's URL hits us,
1132
+ * and both routes share one channel name → one session namespace (the frontSlackChannel shape).
1133
+ */
1134
+ export function frontTeamsChannel(inner, opts = {}) {
1135
+ const innerPost = inner.routes.find((r) => r.method === "POST" && r.path === INTERNAL_TEAMS_ROUTE);
1136
+ if (!innerPost) {
1137
+ throw new Error(`frontTeamsChannel: wrapped teamsChannel must be built with route: "${INTERNAL_TEAMS_ROUTE}" ` +
1138
+ `(found paths: ${inner.routes.map((r) => r.path).join(", ")})`);
1139
+ }
1140
+ const front = buildTeamsFrontHandler(innerPost.handler, opts);
1141
+ return { ...inner, routes: [POST(PUBLIC_TEAMS_ROUTE, front), ...inner.routes] };
1142
+ }
1143
+ //# sourceMappingURL=teams-front.js.map