@open-everywhere/provider-notion 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 (186) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +48 -0
  4. package/agent/channels/notion-acting-identity.ts +88 -0
  5. package/agent/channels/notion-ancestry-trust.ts +458 -0
  6. package/agent/channels/notion-api.ts +1290 -0
  7. package/agent/channels/notion-bot-user.ts +250 -0
  8. package/agent/channels/notion-catch-up.ts +339 -0
  9. package/agent/channels/notion-credentials.ts +417 -0
  10. package/agent/channels/notion-engagement.ts +372 -0
  11. package/agent/channels/notion-event-normalizer.ts +587 -0
  12. package/agent/channels/notion-identity.ts +271 -0
  13. package/agent/channels/notion-ingress-drain.ts +289 -0
  14. package/agent/channels/notion-ingress.ts +182 -0
  15. package/agent/channels/notion-message-ops.ts +140 -0
  16. package/agent/channels/notion-origin-codec.ts +38 -0
  17. package/agent/channels/notion-post-arm.ts +189 -0
  18. package/agent/channels/notion-question-return.ts +223 -0
  19. package/agent/channels/notion-read.ts +283 -0
  20. package/agent/channels/notion-scope.ts +560 -0
  21. package/agent/channels/notion-stop.ts +169 -0
  22. package/agent/channels/notion-supervisor.ts +856 -0
  23. package/agent/channels/notion-webhook-timing.ts +131 -0
  24. package/agent/channels/notion-webhook-verifier.ts +55 -0
  25. package/agent/channels/notion-webhook.ts +963 -0
  26. package/agent/channels/notion-work-search.ts +74 -0
  27. package/agent/channels/notion-work.ts +144 -0
  28. package/agent/channels/notion.ts +1242 -0
  29. package/agent/contributions.ts +39 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/memory/blocks.ts +295 -0
  32. package/agent/memory/memory.ts +622 -0
  33. package/agent/provider-events.ts +198 -0
  34. package/agent/register.ts +144 -0
  35. package/agent/schedules/notion-ingress.ts +20 -0
  36. package/agent/surface-parity.ts +105 -0
  37. package/agent/tools/notion_search.ts +110 -0
  38. package/agent/tools/read_notion_comments.ts +250 -0
  39. package/agent/tools/read_notion_page.ts +351 -0
  40. package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
  41. package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
  42. package/dist/agent/channels/notion-acting-identity.js +85 -0
  43. package/dist/agent/channels/notion-acting-identity.js.map +1 -0
  44. package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
  45. package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
  46. package/dist/agent/channels/notion-ancestry-trust.js +331 -0
  47. package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
  48. package/dist/agent/channels/notion-api.d.ts +655 -0
  49. package/dist/agent/channels/notion-api.d.ts.map +1 -0
  50. package/dist/agent/channels/notion-api.js +938 -0
  51. package/dist/agent/channels/notion-api.js.map +1 -0
  52. package/dist/agent/channels/notion-bot-user.d.ts +107 -0
  53. package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
  54. package/dist/agent/channels/notion-bot-user.js +192 -0
  55. package/dist/agent/channels/notion-bot-user.js.map +1 -0
  56. package/dist/agent/channels/notion-catch-up.d.ts +64 -0
  57. package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
  58. package/dist/agent/channels/notion-catch-up.js +289 -0
  59. package/dist/agent/channels/notion-catch-up.js.map +1 -0
  60. package/dist/agent/channels/notion-credentials.d.ts +210 -0
  61. package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
  62. package/dist/agent/channels/notion-credentials.js +360 -0
  63. package/dist/agent/channels/notion-credentials.js.map +1 -0
  64. package/dist/agent/channels/notion-engagement.d.ts +219 -0
  65. package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
  66. package/dist/agent/channels/notion-engagement.js +283 -0
  67. package/dist/agent/channels/notion-engagement.js.map +1 -0
  68. package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
  69. package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
  70. package/dist/agent/channels/notion-event-normalizer.js +542 -0
  71. package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
  72. package/dist/agent/channels/notion-identity.d.ts +119 -0
  73. package/dist/agent/channels/notion-identity.d.ts.map +1 -0
  74. package/dist/agent/channels/notion-identity.js +205 -0
  75. package/dist/agent/channels/notion-identity.js.map +1 -0
  76. package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
  77. package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
  78. package/dist/agent/channels/notion-ingress-drain.js +185 -0
  79. package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
  80. package/dist/agent/channels/notion-ingress.d.ts +64 -0
  81. package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
  82. package/dist/agent/channels/notion-ingress.js +130 -0
  83. package/dist/agent/channels/notion-ingress.js.map +1 -0
  84. package/dist/agent/channels/notion-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/notion-message-ops.js +126 -0
  87. package/dist/agent/channels/notion-message-ops.js.map +1 -0
  88. package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
  89. package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/notion-origin-codec.js +32 -0
  91. package/dist/agent/channels/notion-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/notion-post-arm.d.ts +3 -0
  93. package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/notion-post-arm.js +185 -0
  95. package/dist/agent/channels/notion-post-arm.js.map +1 -0
  96. package/dist/agent/channels/notion-question-return.d.ts +77 -0
  97. package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
  98. package/dist/agent/channels/notion-question-return.js +183 -0
  99. package/dist/agent/channels/notion-question-return.js.map +1 -0
  100. package/dist/agent/channels/notion-read.d.ts +62 -0
  101. package/dist/agent/channels/notion-read.d.ts.map +1 -0
  102. package/dist/agent/channels/notion-read.js +257 -0
  103. package/dist/agent/channels/notion-read.js.map +1 -0
  104. package/dist/agent/channels/notion-scope.d.ts +188 -0
  105. package/dist/agent/channels/notion-scope.d.ts.map +1 -0
  106. package/dist/agent/channels/notion-scope.js +433 -0
  107. package/dist/agent/channels/notion-scope.js.map +1 -0
  108. package/dist/agent/channels/notion-stop.d.ts +48 -0
  109. package/dist/agent/channels/notion-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/notion-stop.js +141 -0
  111. package/dist/agent/channels/notion-stop.js.map +1 -0
  112. package/dist/agent/channels/notion-supervisor.d.ts +462 -0
  113. package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/notion-supervisor.js +482 -0
  115. package/dist/agent/channels/notion-supervisor.js.map +1 -0
  116. package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
  117. package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
  118. package/dist/agent/channels/notion-webhook-timing.js +99 -0
  119. package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
  120. package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
  121. package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
  122. package/dist/agent/channels/notion-webhook-verifier.js +53 -0
  123. package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
  124. package/dist/agent/channels/notion-webhook.d.ts +242 -0
  125. package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
  126. package/dist/agent/channels/notion-webhook.js +675 -0
  127. package/dist/agent/channels/notion-webhook.js.map +1 -0
  128. package/dist/agent/channels/notion-work-search.d.ts +9 -0
  129. package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
  130. package/dist/agent/channels/notion-work-search.js +69 -0
  131. package/dist/agent/channels/notion-work-search.js.map +1 -0
  132. package/dist/agent/channels/notion-work.d.ts +68 -0
  133. package/dist/agent/channels/notion-work.d.ts.map +1 -0
  134. package/dist/agent/channels/notion-work.js +126 -0
  135. package/dist/agent/channels/notion-work.js.map +1 -0
  136. package/dist/agent/channels/notion.d.ts +95 -0
  137. package/dist/agent/channels/notion.d.ts.map +1 -0
  138. package/dist/agent/channels/notion.js +1081 -0
  139. package/dist/agent/channels/notion.js.map +1 -0
  140. package/dist/agent/contributions.d.ts +32 -0
  141. package/dist/agent/contributions.d.ts.map +1 -0
  142. package/dist/agent/contributions.js +37 -0
  143. package/dist/agent/contributions.js.map +1 -0
  144. package/dist/agent/docs-generated.d.ts +3 -0
  145. package/dist/agent/docs-generated.d.ts.map +1 -0
  146. package/dist/agent/docs-generated.js +13 -0
  147. package/dist/agent/docs-generated.js.map +1 -0
  148. package/dist/agent/memory/blocks.d.ts +58 -0
  149. package/dist/agent/memory/blocks.d.ts.map +1 -0
  150. package/dist/agent/memory/blocks.js +264 -0
  151. package/dist/agent/memory/blocks.js.map +1 -0
  152. package/dist/agent/memory/memory.d.ts +157 -0
  153. package/dist/agent/memory/memory.d.ts.map +1 -0
  154. package/dist/agent/memory/memory.js +471 -0
  155. package/dist/agent/memory/memory.js.map +1 -0
  156. package/dist/agent/provider-events.d.ts +3 -0
  157. package/dist/agent/provider-events.d.ts.map +1 -0
  158. package/dist/agent/provider-events.js +196 -0
  159. package/dist/agent/provider-events.js.map +1 -0
  160. package/dist/agent/register.d.ts +5 -0
  161. package/dist/agent/register.d.ts.map +1 -0
  162. package/dist/agent/register.js +128 -0
  163. package/dist/agent/register.js.map +1 -0
  164. package/dist/agent/schedules/notion-ingress.d.ts +6 -0
  165. package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
  166. package/dist/agent/schedules/notion-ingress.js +16 -0
  167. package/dist/agent/schedules/notion-ingress.js.map +1 -0
  168. package/dist/agent/surface-parity.d.ts +3 -0
  169. package/dist/agent/surface-parity.d.ts.map +1 -0
  170. package/dist/agent/surface-parity.js +99 -0
  171. package/dist/agent/surface-parity.js.map +1 -0
  172. package/dist/agent/tools/notion_search.d.ts +56 -0
  173. package/dist/agent/tools/notion_search.d.ts.map +1 -0
  174. package/dist/agent/tools/notion_search.js +100 -0
  175. package/dist/agent/tools/notion_search.js.map +1 -0
  176. package/dist/agent/tools/read_notion_comments.d.ts +57 -0
  177. package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
  178. package/dist/agent/tools/read_notion_comments.js +236 -0
  179. package/dist/agent/tools/read_notion_comments.js.map +1 -0
  180. package/dist/agent/tools/read_notion_page.d.ts +53 -0
  181. package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
  182. package/dist/agent/tools/read_notion_page.js +327 -0
  183. package/dist/agent/tools/read_notion_page.js.map +1 -0
  184. package/package.json +231 -0
  185. package/tsconfig.build.json +22 -0
  186. package/tsconfig.json +15 -0
@@ -0,0 +1,856 @@
1
+ // The Notion ambient supervisor โ€” the eve-FREE tested core that turns every comment on a
2
+ // connected doc into a JUDGED wake to a standing per-doc supervisor session (the "agent lives in
3
+ // the doc" UX, notion-ambient-surface-design.md). It is the first NON-Slack adopter of the
4
+ // surface-general ambient-supervision pattern (surface-adapter-port.md ยง"Ambient supervision
5
+ // everywhere"), so it is deliberately THIN and mirrors the Slack routeSupervisorEvent shape
6
+ // (slack-events.ts) โ€” GitHub/Linear copy this file next.
7
+ //
8
+ // WHAT'S REUSED WHOLESALE (zero Notion-specific logic in any of it):
9
+ // โ€ข renderWake + the channel-watch preamble (the judge) โ€” normalized-wake.ts
10
+ // โ€ข shouldRotate โ€” the pure rotation policy โ€” slack-events.ts
11
+ // โ€ข the (org, surfaceKey) channel_session registry + the :oe-sup:g spend counter โ€” via deps
12
+ // WHAT'S NEW (this file): the supervisor token/sentinel carrying the :oe-sup:g infix, a pure
13
+ // classifier (self / bot / unresolved-fail-closed / @mention / ambient), the NormalizedWake
14
+ // builder, and the dispatch-core (session get-or-create โ†’ build โ†’ wake).
15
+ //
16
+ // The Notion doc-session is the container-session's ORCHESTRATION HALF ONLY (surface-adapter-port
17
+ // ยง"container session"): a page-scoped standing session aware of all the doc's discussions,
18
+ // holding page memory, that judges every comment and replies into the triggering discussion.
19
+ // There is NO container-conversation half โ€” a Notion doc has no top-level conversation to post to.
20
+
21
+ import {
22
+ spreadVendorTs,
23
+ type VendorEventTime,
24
+ renderWake,
25
+ mkMessageTrigger,
26
+ shouldRotate,
27
+ mintWakeId,
28
+ singleWindowMentionPolicy,
29
+ matchesAgentName,
30
+ stampNonMemberChatAuth,
31
+ resolveExternalPolicy,
32
+ withholdsBody,
33
+ withholdWakeBodies,
34
+ ingestedAfterWithholding,
35
+ type ExternalPolicy,
36
+ type IngestedChatMessage,
37
+ type NormalizedWake,
38
+ type WakeEngagementTier,
39
+ type WakeMessageInput,
40
+ type WakeTrust,
41
+ type UnifiedWindowWake,
42
+ tailAuthorGrain,
43
+ type SessionTailWakeRow,
44
+ seatCatchUpSinceMs,
45
+ type SeatCatchUpRequest,
46
+ type SeatCatchUpResult,
47
+ engagedFollowUpContract,
48
+ } from "@open-everywhere/core/agent/adapter-api.js";
49
+ import { mentionsUser, richTextToPlainText, type NotionComment } from "./notion-api.js";
50
+ import type { NotionOwnBotIds } from "./notion-bot-user.js";
51
+ import type { OpenEverywhereNotionAuth } from "./notion-identity.js";
52
+ import type { NotionDeliveryTrust } from "./notion-ancestry-trust.js";
53
+ import type { NotionSessionState } from "./notion-webhook.js";
54
+ import type { NotionVia } from "./notion-scope.js";
55
+ // The ยง2.5 engagement writer (OPE-1501). A VALUE import, not a type โ€” the dispatch core calls it as
56
+ // the default for `NotionSupervisorDeps.markEngaged`. `notion-engagement.ts` imports only
57
+ // `notion-identity.ts` and core, so this adds no cycle.
58
+ import { markNotionEngaged, type NotionSeamFacts } from "./notion-engagement.js";
59
+
60
+ /** The catch-up's REACHBACK FLOOR (hours): how far back a seat that has been quiet for a long time
61
+ * reads. 7 days, the same floor every other surface uses. */
62
+ const NOTION_CATCH_UP_REACHBACK_HOURS = 168;
63
+
64
+ // --- supervisor token / sentinel (carry the :oe-sup:g infix โ€” load-bearing) ----------
65
+ //
66
+ // hooks/spend.ts isSupervisorToken keys off the `:oe-sup:g` substring to (a) tag the usage-event
67
+ // `kind="supervisor"` and (b) drive the per-generation cumulative-token rotation counter. Carry
68
+ // the infix and the ENTIRE cost/rotation apparatus works for free (design ยง3.1). The sentinel
69
+ // mirrors Slack's; the token puts the pageId in the channel-id slot, `notion:`-namespaced.
70
+
71
+ /** The reserved pseudo-thread sentinel for a supervisor generation (mirrors Slack's). */
72
+ export function notionSupervisorSentinel(generation: number): string {
73
+ return `oe-sup:g${generation}`;
74
+ }
75
+
76
+ /** The reserved continuation token for a page's supervisor session at a given generation:
77
+ * `notion:<pageId>:oe-sup:g<generation>`. Contains the `:oe-sup:g` infix hooks/spend.ts keys on,
78
+ * and is stable+explicit by construction (a custom-channel token eve never re-anchors โ€” so,
79
+ * UNLIKE Slack, no non-empty state.threadTs re-anchor sentinel is needed; design ยง3.1). */
80
+ export function notionSupervisorToken(pageId: string, generation: number): string {
81
+ return `notion:${pageId}:${notionSupervisorSentinel(generation)}`;
82
+ }
83
+
84
+ // UNIFIED cross-surface dispatch (formerly TODO(unified-dispatch) โ€” wired 2026-07-11), same shape
85
+ // as the Slack reference wiring (channels/slack-events.ts liveSupervisorDeps): when the page's
86
+ // domain has opted in (both gates on), the LIVE deps in notion.ts key the registry ROW + TOKEN by
87
+ // `(org, domain)` via channels/unified-supervisor.ts supervisorGrainFor/supervisorTokenForGrain, so
88
+ // a Notion comment dispatches into the domain's SHARED session, and this core stamps
89
+ // `<channel platform="notion">` + the P5 disclosure on the judged wake (`unified` on the dispatch
90
+ // input; a `direct` wake renders no envelope, so the stamp is judged-path-only by construction).
91
+ // Gated โ†’ byte-identical off. The REPLY leg was per-wake already (createCommentReply targets the
92
+ // turn.started snapshot's discussionId), and the unified path additionally passes the terminal
93
+ // completion through the message-tool-only silence gate in notion.ts (Notion has a WORKING `reply`
94
+ // tool on supervisor turns, so terminal prose is dropped there โ€” the OpenClaw-steal hard gate).
95
+ // See unified-supervisor.ts's cross-surface-reply header for the whole model + the eve
96
+ // session-namespacing caveat.
97
+
98
+ // --- the classifier (pure) -----------------------------------------------------------
99
+
100
+ /** How a comment addresses the doc supervisor. `drop` = never wake (loop/bot guards); `direct` =
101
+ * a structured bot @mention, the guaranteed-engage conversation-driver treatment; `channel-watch`
102
+ * = judged by the standing supervisor (the ambient default โ€” most comments). */
103
+ export type NotionAddressing = "direct" | "channel-watch" | "drop";
104
+
105
+ export interface NotionClassification {
106
+ readonly addressing: NotionAddressing;
107
+ readonly reason: string;
108
+ }
109
+
110
+ /**
111
+ * The Notion synthetic-mention (name-drop) convention. Notion CANNOT @mention an internal
112
+ * integration (notion-webhook.ts โ€” the structured `mentionsUser` path is STRUCTURALLY DEAD for the
113
+ * agent), so a person addresses the doc agent by NAME instead: the deployment's configured names
114
+ * (OE_AGENT_NAME / OE_AGENT_SHORT_NAME, defaulting to the brand "open everywhere" / "oe" when unset)
115
+ * or the platform-resolved integration name (botName). This is the ONLY ambient-INDEPENDENT directed
116
+ * input Notion has, so โ€” unlike Slack/GitHub/Linear, where a name-drop stays a judged input because a
117
+ * real @mention is available โ€” here it is promoted to `direct`.
118
+ *
119
+ * The name set + word-boundary matcher live in core (identity/bot-name.ts matchesAgentName), which
120
+ * is ALSO what the addressed link-claim recogniser strips its leading token with (hooks/link-claim.ts)
121
+ * โ€” one source of truth for "what does this agent answer to". Word-boundary matched (`\b`) so the
122
+ * short "oe" token never fires inside "Zoe"/"poet"/"does". A hit flows through the SAME seam a real
123
+ * @mention would: planNotionSingleWindow stamps `direct-mention`, which marks the wake as OWED a
124
+ * reply and tells the seat so. ๐Ÿ”ด THE STAMP DELIVERS NOTHING BY ITSELF (OPE-1982): the terminal-prose
125
+ * bridge it used to arm is deleted, so the reply lands via createCommentReply only when the seat
126
+ * calls `reply` โ€” on its own turn, or on the ONE corrective re-drive a prose ending earns.
127
+ */
128
+ export function notionNameDrop(text: string, botName?: string): boolean {
129
+ return matchesAgentName(text, botName);
130
+ }
131
+
132
+ /**
133
+ * Decide how the doc supervisor should treat one comment. The self-reply loop guard is HARDENED
134
+ * here (design ยง4.2), in cost order:
135
+ * 1. UNRESOLVED PRIMARY bot user id โ‡’ DROP (fail closed) โ€” self-identity is a precondition for the
136
+ * loop guard; without it the agent would loop on its own replies (each createCommentReply fires a
137
+ * fresh comment.created). This is the critical hardening over the old `if (botUserId && โ€ฆ)`.
138
+ * 2. SELF-authored โ‡’ DROP โ€” our own posted reply. The comparison is `created_by.id` against
139
+ * {@link NotionOwnBotIds.all}: EVERY configured integration's bot user id, not only the one that
140
+ * verified this delivery (OPE-1921). THAT IS THE WHOLE GUARD.
141
+ * ๐Ÿ”ด THE SET IS WHAT MAKES IT A GUARD ON A DEPLOYMENT RATHER THAN ON ONE INTEGRATION. Two
142
+ * integrations can both see one page, so a reply integration A posted is delivered on B's
143
+ * subscription too. Compared against B's id alone it matched nothing, read as a person and woke
144
+ * a turn; A's subscription then saw B's answer. Each reply is a NEW comment id, so the OPE-1906
145
+ * once-guard cannot bound that loop โ€” only recognising our own words can.
146
+ * 3. UNIDENTIFIED AUTHOR (no `created_by.id`) โ‡’ DROP (fail closed) โ€” step 2 is the only check that
147
+ * can recognise our own words, so a comment whose author id we cannot read defeats it.
148
+ * ๐Ÿ”ด WITHOUT THIS STEP THE GUARD FALLS BACK ON `created_by.type`, A FIELD NOTION MAY OMIT
149
+ * (OPE-1857, notion-api.ts NotionComment): a comment's `created_by` is a PARTIAL user, so an
150
+ * id-less author reached step 4 with nothing to test, read as a person, and woke a turn. That
151
+ * is the same fail-closed posture step 1 takes for the other half of the comparison โ€” an
152
+ * identity we cannot establish never wakes the agent, in EITHER direction.
153
+ * 4. BOT/integration-authored (created_by.type present and not "person") โ‡’ DROP โ€” the Slack
154
+ * authorIsBot analog, prevents bot-to-bot loops. A BACKSTOP for a foreign bot, never the
155
+ * self-guard: an ABSENT `type` says nothing, and steps 2-3 have already answered "is it ours".
156
+ * 5. INCOMPLETE OWN-ID SET โ‡’ DROP (fail closed, OPE-1921) โ€” one configured integration's bot user
157
+ * id did not resolve, so step 2 could not have recognised that integration's own reply. An
158
+ * author Notion states is a `person` is still admitted; an author it says NOTHING about is
159
+ * exactly the ambiguity the loop rides, so it drops.
160
+ * ๐Ÿ”ด IT COSTS AVAILABILITY AND THE TRADE IS DELIBERATE โ€” see {@link NotionOwnBotIds.complete}.
161
+ * A deployment that cannot resolve one integration's id is deaf to every comment Notion does not
162
+ * mark `person`-authored until it can. ๐Ÿ”ด THAT IS NOT ONLY AMBIENT CHATTER: this step sits ABOVE
163
+ * steps 6 and 7, so an addressed structured @mention, an addressed name-drop and an
164
+ * `oe link <code>` identity claim all drop with it. Failing OPEN instead buys an unbounded reply
165
+ * loop between two of our own integrations, which no once-guard can stop.
166
+ * 6. STRUCTURED bot @mention โ‡’ direct (guaranteed engage). Structurally unreachable for an
167
+ * internal integration today, but kept as the canonical addressed signal. Tested against the
168
+ * PRIMARY id alone: a structured mention names ONE bot user, and answering a mention of a
169
+ * sibling integration would put this integration in a conversation it was not addressed in.
170
+ * 7. NAME-DROP โ‡’ direct ({@link notionNameDrop}). Notion's ONLY addressed path (no integration
171
+ * @mention), so a brand/name hit is promoted to the owed-a-reply treatment, not left as a
172
+ * judged input โ€” this is the deliberate Notion-specific divergence from Slack/GitHub/Linear.
173
+ * 8. otherwise โ‡’ channel-watch (judged by the standing doc supervisor).
174
+ *
175
+ * ๐Ÿ”ด THIS FUNCTION GUARDS ONE COMMENT, NOT ONE WAKE. It cannot tell a second delivery of the SAME
176
+ * comment from the first, because both carry identical bytes. The delivery once-guard is the other
177
+ * half of the loop defence and lives at the ingress
178
+ * (notion-engagement.ts `firstNotionDispatchOfComment`, called by notion-webhook.ts).
179
+ */
180
+ export function classifyNotionComment(comment: NotionComment, own: NotionOwnBotIds, botName?: string): NotionClassification {
181
+ if (!own.primary) return { addressing: "drop", reason: "unresolved bot user id โ€” fail-closed loop guard" };
182
+ const author = comment.created_by;
183
+ if (author?.id !== undefined && own.all.has(author.id)) {
184
+ // The two are named apart because they send an operator to different places: our own reply
185
+ // coming back is ordinary, and a SIBLING integration's reply arriving here is the OPE-1921
186
+ // delivery shape โ€” the one that used to wake a turn.
187
+ const sibling = author.id !== own.primary;
188
+ return { addressing: "drop", reason: sibling ? "self-authored comment (a sibling integration of this deployment)" : "self-authored comment (our own reply)" };
189
+ }
190
+ if (!author?.id) return { addressing: "drop", reason: "unidentified author (no created_by.id) โ€” fail-closed loop guard" };
191
+ if (author.type && author.type !== "person") return { addressing: "drop", reason: `bot/integration-authored (${author.type})` };
192
+ if (!own.complete && author.type !== "person") {
193
+ return { addressing: "drop", reason: "a configured integration's bot user id did not resolve, and this author is unattributable โ€” fail-closed loop guard" };
194
+ }
195
+ if (mentionsUser(comment.rich_text, own.primary)) return { addressing: "direct", reason: "structured bot @mention" };
196
+ if (notionNameDrop(richTextToPlainText(comment.rich_text), botName)) {
197
+ return { addressing: "direct", reason: "bot name-drop (synthetic mention โ€” Notion has no @mention for integrations)" };
198
+ }
199
+ return { addressing: "channel-watch", reason: "ambient comment โ€” judged by the doc supervisor" };
200
+ }
201
+
202
+ // --- the single-window plan (pure) ----------------------------------------------------
203
+
204
+ /** The host-frozen reply-target facts one comment.created delivery resolves (the webhook core's
205
+ * dispatch fields) โ€” everything {@link planNotionSingleWindow} needs to mint the wake origin. */
206
+ export interface NotionSingleWindowTarget {
207
+ /** The trigger comment's inline discussion thread โ€” createCommentReply's discussion_id target.
208
+ * Empty โ‡’ no freezable reply target โ‡’ NO window (fail closed, never an unresolvable wake). */
209
+ readonly discussionId: string;
210
+ readonly pageId?: string;
211
+ readonly workspaceId?: string | null;
212
+ /** The Notion integration this delivery VERIFIED under (OPE-467) โ€” frozen into the origin so the
213
+ * unified reply posts under the credential that fetched the trigger comment. */
214
+ readonly credentialId?: string;
215
+ /** The first latency anchor (OPE-1716), recorded on the ingress row. A Notion delivery carries no
216
+ * vendor clock this seam can read, so the caller passes its own FRONT-DOOR RECEIPT โ€” which makes
217
+ * the vendorโ†’ingress segment a lower bound, and the row's `vendorTsSource` says so. */
218
+ readonly vendorTs?: VendorEventTime;
219
+ }
220
+
221
+ /**
222
+ * The ONE single-window decision for a Notion delivery (channels/unified-window.ts
223
+ * singleWindowMentionPolicy is the SHARED seam โ€” the exact function the GitHub reference's
224
+ * native-suppression AND classifier consult, github.ts:261 / github-supervisor.ts:139): given
225
+ * the grain's `singleWindow`, whether the classified comment is a structured @mention
226
+ * (classifyNotionComment returned `direct`), and the PRIVACY FLOOR `privacyOk`, return the
227
+ * {@link UnifiedWindowWake} the dispatch should carry, or undefined for the byte-identical
228
+ * native path.
229
+ *
230
+ * `privacyOk` is the OPERATOR-VOUCH fact the caller resolves (notion.ts): identity/trust.ts
231
+ * isVouchedInternal on the page's workspaceKey/surfaceKey (or the OE_TRUST=internal blanket
232
+ * switch), fail-closed when unregistered. This is the SAME per-surface allowlist that gates
233
+ * Notion's shared MEMORY (notion-identity.ts / bindNotion), so a page is admitted to the shared
234
+ * cross-surface WINDOW exactly when the operator has vouched it internal โ€” never inferred from a
235
+ * misleading API field (a Notion page's `public_url` is set ONLY by publish-to-web and is null
236
+ * for the common guest-shared vector, so it would admit an external guest's mention; the vouch
237
+ * register is the doctrine-correct floor โ€” operator vouches, code enforces, no inference).
238
+ *
239
+ * Notion's mention signal lives in ONE webhook delivery (unlike Slack's dual-event), and its
240
+ * "native" handler IS this same supervisor dispatch โ€” so suppress-native and divert are the SAME
241
+ * act here: a window-carrying wake is delivered through the unified channel instead of the notion
242
+ * channel's own send (notion.ts's wake dep), and both legs read THIS one verdict off the one
243
+ * parsed event โ€” structurally never a double-handle, never a gap:
244
+ * โ€ข @mention on a singleWindow, VOUCHED-internal page โ†’ the seam suppresses the native session
245
+ * and stamps `addressing:"direct-mention"` (the terminal-prose delivery bridge's arm);
246
+ * โ€ข ambient (channel-watch) comment on the same page โ†’ Leg A: the window WITHOUT the addressed
247
+ * stamp (judged treatment in the shared window, prose stays hard-dropped);
248
+ * โ€ข un-vouched page, or singleWindow off, or no discussion id โ†’ undefined (the native
249
+ * per-doc/domain path, byte-identical โ€” a mention there keeps today's `direct` wake).
250
+ */
251
+ export function planNotionSingleWindow(
252
+ singleWindow: boolean,
253
+ isMention: boolean,
254
+ privacyOk: boolean,
255
+ target: NotionSingleWindowTarget,
256
+ ): UnifiedWindowWake | undefined {
257
+ const policy = singleWindowMentionPolicy(singleWindow, isMention, privacyOk);
258
+ // A mention diverts iff the seam suppresses the native session; an ambient comment diverts on
259
+ // the same `singleWindow && privacyOk` floor (Leg A โ€” never a public/unknown page into the
260
+ // shared window). Both derive from the seam's one predicate.
261
+ const divert = isMention ? policy.suppressNative : singleWindow && privacyOk;
262
+ if (!divert) return undefined;
263
+ if (!target.discussionId) return undefined; // no frozen reply target โ‡’ no window (fail closed)
264
+ return {
265
+ wakeId: mintWakeId(),
266
+ origin: {
267
+ platform: "notion",
268
+ discussionId: target.discussionId,
269
+ ...(target.pageId ? { pageId: target.pageId } : {}),
270
+ ...(target.workspaceId ? { workspaceId: target.workspaceId } : {}),
271
+ ...(target.credentialId ? { credentialId: target.credentialId } : {}),
272
+ },
273
+ // ADDRESSED @mention โ‡’ the seam's stamp marks the wake as OWED a reply, so the envelope tells
274
+ // the seat it owes an answer through the `reply` tool. Ambient wakes omit it. It is not a delivery
275
+ // path: a turn that ends in prose is dropped and re-driven once, on every addressing (OPE-1982).
276
+ ...(policy.addressing !== undefined ? { addressing: policy.addressing } : {}),
277
+ ...spreadVendorTs(target.vendorTs),
278
+ };
279
+ }
280
+
281
+ // --- the wake builder (pure) ---------------------------------------------------------
282
+
283
+ /** One comment reshaped for the wake โ€” the trigger, or a discussion-sibling backscroll message.
284
+ * `from` is resolved by the caller (self when the bot authored it, else human). */
285
+ export interface NotionWakeMessage {
286
+ readonly from: "human" | "self";
287
+ readonly id: string;
288
+ readonly authorId?: string;
289
+ readonly sentAt: string;
290
+ readonly text: string;
291
+ /** Authenticated parent block of an inline comment. Absent for page-level discussions. */
292
+ readonly anchorId?: string;
293
+ /**
294
+ * Resolved per-message trust tier (identity/wake-trust.ts โ€” the audit-wedge F3 assignment). The
295
+ * live dispatch (notion.ts) resolves it off the binding gate + admin rosters, for the trigger AND
296
+ * every discussion sibling.
297
+ *
298
+ * ๐Ÿ”ด REQUIRED, NOT OPTIONAL (OPE-596) โ€” see LinearSupervisorWakeInput.trigger.trust. The trigger
299
+ * used to default to `principal` on the direct path and a sibling to `peer`, both fail-OPEN, and
300
+ * since OPE-577 the tier decides the `<untrusted_external_data>` fence. A caller that cannot
301
+ * resolve a tier states the fail-closed one itself: `relay` for a machine author, `unauth` for an
302
+ * unvouchable human.
303
+ */
304
+ readonly trust: WakeTrust;
305
+ }
306
+
307
+ /**
308
+ * The SAME message BEFORE any author tier is resolved โ€” what notion-webhook.ts produces (OPE-596).
309
+ *
310
+ * The webhook core reads the comment payload; it holds no binding context, so it cannot answer "what
311
+ * standing does this author carry?". The ROUTE answers that, once, for the trigger and every
312
+ * sibling alike (notion.ts `trustOf`, fail-closed to `relay`). This type states that split instead
313
+ * of leaving it to a convention: an untiered message is NOT a {@link NotionWakeMessage} and the
314
+ * compiler will not let one reach {@link buildNotionSupervisorWake}. Before OPE-596 the same two
315
+ * shapes shared one type and the builder defaulted the missing tier UPWARD, so a route leg that
316
+ * forgot the stamp would have rendered a stranger's comment as a member's.
317
+ */
318
+ export type NotionUntieredWakeMessage = Omit<NotionWakeMessage, "trust">;
319
+
320
+ export interface NotionSupervisorWakeInput {
321
+ /** direct (guaranteed) or channel-watch (judged) โ€” `drop` never reaches here. */
322
+ readonly addressing: Exclude<NotionAddressing, "drop">;
323
+ readonly pageId: string;
324
+ readonly discussionId: string;
325
+ readonly trigger: NotionWakeMessage;
326
+ /** The OTHER comments in the same discussion, oldest-first โ€” envelope backscroll siblings.
327
+ * Re-sent every wake (cheap at Notion volume) so the judge always has full thread context,
328
+ * even mid-session (design ยง3.2). Ignored on the `direct` path (renderWake returns the body
329
+ * verbatim there). */
330
+ readonly threadComments?: readonly NotionWakeMessage[];
331
+ /** The OUTGOING generation's recorded TAIL (OPE-794) โ€” rendered BEFORE this wake's own thread
332
+ * comments, so the envelope reads oldest-first across the rotation boundary. Only a FRESH
333
+ * generation's first wake carries one. */
334
+ readonly tail?: readonly WakeMessageInput[];
335
+ /** This surface could not read its own page discussion since the seat's last wake (core
336
+ * channels/seat-catch-up.ts). Rendered as a trusted `<system-note>`; unset โ‡’ no note. */
337
+ readonly catchUpUnavailable?: string;
338
+ /** Engagement calibration for the judged preamble (memory/domain.ts engagementTierFor โ€”
339
+ * resolved by the caller; this core stays config-free). Unset โ‡’ neutral โ‡’ byte-identical. */
340
+ readonly engagementTier?: WakeEngagementTier;
341
+ // ๐Ÿ”ด NO `unified` (OPE-770). It existed to stamp `<channel platform>` and the P5 disclosure
342
+ // sentence, and both are gone: the render names no surface and the standing block states the
343
+ // cross-surface rule once per session. A notion wake is the same wake whether or not its session
344
+ // spans surfaces. The unified GRAIN decision is unaffected โ€” it lives on the dispatch input.
345
+ /** Host-minted wake id (rendered `<wake wake-id=โ€ฆ>`) โ€” SINGLE-WINDOW path only (channels/
346
+ * unified-window.ts): the id the model echoes to `reply` so the host resolves this wake's
347
+ * origin from the map. Unset โ‡’ byte-identical (no attribute) โ€” mirrors
348
+ * GitHubSupervisorWakeInput.wakeId. */
349
+ readonly wakeId?: string;
350
+ /** SINGLE-WINDOW addressed @mention (mirrors GitHubSupervisorWakeInput.directMention): render
351
+ * the wake as `direct-mention` โ€” the full judged ENVELOPE (so the `wake-id` survives for the
352
+ * reply tool, the ONLY reply route on the unified channel) with the ADDRESSED preamble instead
353
+ * of the silence-biased watch one. Only meaningful alongside `addressing: "direct"` (the plan
354
+ * only sets it for a real @mention); unset โ‡’ today's render, byte-identical โ€” a plain `direct`
355
+ * wake stays the verbatim body. */
356
+ readonly directMention?: boolean;
357
+ /**
358
+ * This page carries an ENGAGED registry row โ€” the seam's `deliver` verdict โ€” so a JUDGED wake
359
+ * states `follow-up="thread-engaged"`, the envelope's own words for "you are already in this
360
+ * conversation and `stand_down` is how you leave" (instructions/access.ts's standing block).
361
+ *
362
+ * ๐Ÿ”ด THE ROW ALREADY SAYS IT AND THE ENVELOPE DID NOT (OPE-1711). `notionWakeAddressing` promotes
363
+ * the `notion.dispatched` row to `thread-follow` off this same verdict, so the deployment knows the
364
+ * page is enrolled and tells its audit trail โ€” while the seat reads bare ambient activity. Ship
365
+ * #197 measured the cost of that gap on LINEAR, whose seam has the identical shape: a standing
366
+ * session declined an engaged follow-up as `user_requested_silence` on a stop asked in an earlier
367
+ * conversation. The fix is OPE-1673's, made on Slack and ported to both ticket surfaces.
368
+ *
369
+ * Like Slack's it does NOT touch `addressing`: `channel-watch` stays the honest silence bar for a
370
+ * standing session, and enrolment is a different fact from that bar. Unset/false โ‡’ no attribute.
371
+ */
372
+ readonly threadEngaged?: boolean;
373
+ }
374
+
375
+ function toWakeSibling(m: NotionWakeMessage): WakeMessageInput {
376
+ return {
377
+ from: m.from,
378
+ author: m.authorId ?? (m.from === "self" ? "the doc agent" : "unknown"),
379
+ authorId: m.authorId,
380
+ id: m.id,
381
+ sentAt: m.sentAt,
382
+ trust: m.trust,
383
+ body: m.text,
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Build the NormalizedWake for one doc-supervisor turn. platform "notion", containerNoun "doc",
389
+ * locus.channel = the page, locus.thread = the discussion, backscroll = the discussion's other
390
+ * comments as oldest-first envelope siblings, ending at the triggering comment. renderWake then
391
+ * emits the exact `{message, context}` shape every OE dispatch sends โ€” the SAME renderer Slack
392
+ * uses, with NO platform branch. A `direct` wake carries no preamble/backscroll (the body is the
393
+ * driver, verbatim); a `channel-watch` wake gets the judged envelope + doc-noun preamble.
394
+ */
395
+ export function buildNotionSupervisorWake(input: NotionSupervisorWakeInput): NormalizedWake {
396
+ // SINGLE-WINDOW addressed @mention: an ENVELOPED render (wake-id + backscroll survive โ€” the
397
+ // reply tool needs the id) with the addressed preamble. Guarded on `direct` so the flag can
398
+ // never re-shape a channel-watch wake; unset โ‡’ today's renders, byte-identical.
399
+ const directMention = input.directMention === true && input.addressing === "direct";
400
+ const judged = input.addressing === "channel-watch";
401
+ const enveloped = judged || directMention;
402
+ // The carried TAIL leads, then this wake's own thread comments โ€” one oldest-first list that
403
+ // reads straight across the rotation boundary (OPE-794).
404
+ const backscroll = enveloped ? [...(input.tail ?? []), ...(input.threadComments ?? []).map(toWakeSibling)] : undefined;
405
+ return {
406
+ platform: "notion",
407
+ reason: input.addressing === "direct" ? "mention" : "channel-activity",
408
+ addressing: directMention ? "direct-mention" : input.addressing,
409
+ containerNoun: "doc",
410
+ // The ambient calibration dial โ€” an addressed mention is owed an answer, so watch-only.
411
+ engagementTier: judged ? input.engagementTier : undefined,
412
+ // The ENROLMENT fact ({@link NotionSupervisorWakeInput.threadEngaged}), through the ONE shared
413
+ // helper every enveloped surface now calls (channels/engaged-wake.ts, OPE-1740). It keeps both
414
+ // exclusions this line used to spell out: an addressed comment is owed an answer whatever the
415
+ // page's enrolment says, and a `direct` wake carries no envelope to hang an attribute on.
416
+ followUp: engagedFollowUpContract({
417
+ addressing: directMention ? "direct-mention" : input.addressing,
418
+ threadEngaged: input.threadEngaged,
419
+ }),
420
+ // Single-window wake id โ€” only the singleWindow dispatch passes it (byte-identical off).
421
+ wakeId: input.wakeId,
422
+ locus: {
423
+ channel: { id: input.pageId },
424
+ thread: { ts: input.discussionId },
425
+ },
426
+ backscroll,
427
+ ...(input.catchUpUnavailable !== undefined ? { catchUpUnavailable: input.catchUpUnavailable } : {}),
428
+ trigger: mkMessageTrigger({
429
+ from: input.trigger.from,
430
+ author: input.trigger.authorId ?? "unknown",
431
+ authorId: input.trigger.authorId,
432
+ id: input.trigger.id,
433
+ sentAt: input.trigger.sentAt,
434
+ // The caller's stated per-person tier โ€” no default (OPE-596). Directness is the `mention`
435
+ // flag below, never a trust tier.
436
+ trust: input.trigger.trust,
437
+ mention: input.addressing === "direct",
438
+ body: input.trigger.text,
439
+ }),
440
+ };
441
+ }
442
+
443
+ // --- the dispatch core (session get-or-create โ†’ build โ†’ wake โ†’ terminal rotate) -------
444
+
445
+ /** The live supervisor session pointer for a page, as the deps report it. `freshGeneration` is
446
+ * true for a brand-new OR just-rotated generation โ€” its first wake carries the carried tail. */
447
+ export interface NotionSupervisorSession {
448
+ readonly token: string;
449
+ readonly generation: number;
450
+ readonly freshGeneration: boolean;
451
+ /** The OUTGOING generation's recorded tail, oldest-first โ€” empty unless this is a fresh
452
+ * generation's first wake (OPE-794). */
453
+ readonly tail: readonly WakeMessageInput[];
454
+ /** The session's PREVIOUS wake time (the stored lastWakeAt, read BEFORE touchSession advances it),
455
+ * or null/absent when there is no prior delivered wake (a fresh/rotated generation). The cursor
456
+ * the memory-delta push (deps.memoryDelta) diffs against; unset โ‡’ no delta computed. */
457
+ readonly lastWakeAt?: string | null;
458
+ }
459
+
460
+ /** Deps for {@link handleNotionSupervisorDispatch} โ€” mirror SupervisorRouteDeps (slack-events.ts),
461
+ * injected so the core is unit-testable over a mock store + a capture `wake`. org is baked into
462
+ * the live impl's closures (a deployment fact โ€” notion.ts), so it never crosses this boundary. */
463
+ export interface NotionSupervisorDeps {
464
+ /** Create-or-fetch the live supervisor session for this page's surfaceKey: mint generation 1 on
465
+ * first sighting, rotate to generation+1 when {@link shouldRotate} says it aged out, else
466
+ * return the current generation. */
467
+ readonly getOrCreateSession: (surfaceKey: string, pageId: string, atIso: string) => Promise<NotionSupervisorSession>;
468
+ /** Record one wake on the current generation (lastWakeAt/turnCount). Best-effort. */
469
+ readonly touchSession?: (surfaceKey: string, atIso: string) => Promise<void>;
470
+ /**
471
+ * THE SINCE-LAST-WAKE CATCH-UP, through the ONE shared path every surface takes
472
+ * (channels/seat-catch-up.ts `seatCatchUp`). Optional so older test doubles keep compiling
473
+ * (absent โ‡’ no catch-up, the pre-OPE-1726 behaviour); fail-soft by construction.
474
+ */
475
+ readonly catchUp?: (req: SeatCatchUpRequest) => Promise<SeatCatchUpResult>;
476
+ /**
477
+ * THE ยง2.5 ENGAGEMENT MARK for an ADDRESSED wake (OPE-1501) โ€” defaults to
478
+ * `channels/notion-engagement.ts`'s `markNotionEngaged`, which writes the live registry.
479
+ *
480
+ * ๐Ÿ”ด IT IS A DEP BECAUSE OF WHERE IT HAS TO RUN, not for testability alone. Notion is the one
481
+ * surface whose EXTERNAL-AUTHOR gate is not at the ingress: the webhook core dispatches, and the
482
+ * decision is made HERE, one step later. Marking at the ingress would let an author the operator
483
+ * excluded ENROL a page by naming the agent on it โ€” no reply, but every later plain comment from
484
+ * anyone would then wake a turn โ€” and would let that same suppressed name-drop CLEAR a
485
+ * stand-down, because `participating: true` lifts the `stopped` flag. So the mark sits below the
486
+ * suppression check, where the answer already exists, and this dep is how the webhook core's own
487
+ * test can drive it. Absent in a unit test โ‡’ the live writer.
488
+ */
489
+ readonly markEngaged?: (d: NotionSupervisorDispatch) => Promise<void>;
490
+ /**
491
+ * Record this delivered wake onto the LIVE generation's TAIL ring (OPE-794). Injected because the
492
+ * entry has to be filed under an ORG and a registry key, and this core deliberately holds neither
493
+ * โ€” the live wiring does. Absent (a unit test) โ‡’ nothing is recorded and the dispatch is
494
+ * unchanged, which is the same posture `touchSession` takes.
495
+ */
496
+ readonly recordWake?: (entry: SessionTailWakeRow, token: string, surfaceKey: string) => Promise<void>;
497
+
498
+ /** Start/continue the supervisor session under its reserved token, posting the rendered wake.
499
+ * `window` is present ONLY on the SINGLE-WINDOW path (the dispatch input carried it): the
500
+ * wiring (notion.ts) then DIVERTS the wake through eve's cross-channel `receive` into the
501
+ * synthetic `unified` channel instead of this channel's own send. Absent โ‡’ byte-identical. */
502
+ readonly wake: (input: {
503
+ token: string;
504
+ auth: OpenEverywhereNotionAuth;
505
+ message: string;
506
+ context: readonly string[];
507
+ state: NotionSessionState;
508
+ window?: UnifiedWindowWake;
509
+ }) => Promise<void>;
510
+ /** Compute the "changed since your last wake" `<system-reminder>` PUSH (memory/wake-delta.ts):
511
+ * given the session's previous wake time (`sinceIso`), this wake's time (`atIso`), and this
512
+ * session's own key (`selfKey` = its continuation token, to exclude its own writes), return the
513
+ * ready block or null. Injected so the pure dispatch stays testable; the live wiring (notion.ts)
514
+ * sets it ONLY when armed (supervisor + unified org gate), so unset โ‡’ byte-identical. */
515
+ readonly memoryDelta?: (sinceIso: string, atIso: string, selfKey?: string) => Promise<string | null>;
516
+ /** Clock seam (defaults to the real clock). */
517
+ readonly now?: () => string;
518
+ }
519
+
520
+ /** Everything notion-webhook hands the supervisor dispatch: the classification + the wake parts +
521
+ * the identity/session facts. Built by the webhook core, consumed here. */
522
+ export interface NotionSupervisorDispatch {
523
+ readonly surfaceKey: string;
524
+ /**
525
+ * The Notion channel credential this delivery runs under (OPE-467) โ€” the integration whose
526
+ * secret verified the signature, else (dev hatch only) the page's domain binding. Selected ONCE, by the webhook core,
527
+ * and carried here so the route's own Notion calls (the spend gate's visible refusal comment)
528
+ * cannot re-derive a DIFFERENT one. Nothing in this module reads it; it exists so there is
529
+ * exactly one selection per delivery rather than two rules that agree by luck.
530
+ */
531
+ readonly credentialId: string;
532
+ /** direct | channel-watch โ€” `drop` is filtered upstream (never dispatched). */
533
+ readonly addressing: Exclude<NotionAddressing, "drop">;
534
+ readonly pageId: string;
535
+ readonly discussionId: string;
536
+ readonly workspaceId: string | null;
537
+ readonly auth: OpenEverywhereNotionAuth;
538
+ readonly trigger: NotionWakeMessage;
539
+ readonly threadComments: readonly NotionWakeMessage[];
540
+ /** Engagement calibration tier for the judged preamble (resolved by the webhook core). */
541
+ readonly engagementTier?: WakeEngagementTier;
542
+ /**
543
+ * The EXTERNAL-PARTICIPANT POLICY for this page (OPE-507), RESOLVED BY THE CALLER โ€” this core
544
+ * stays config-free, exactly as {@link NotionSupervisorDispatch.engagementTier} does. The live
545
+ * wiring (notion.ts) resolves it as `resolveExternalPolicy(externalPolicyOverrideFor(surfaceKey,
546
+ * domains))`, so the org default and the domain override are both applied before it arrives here.
547
+ *
548
+ * Unset โ‡’ the ORG DEFAULT alone (`resolveExternalPolicy()`), which ships `taint` โ‡’ byte-identical
549
+ * to before this field existed. Every pure/test caller therefore stays unchanged.
550
+ */
551
+ readonly externalPolicy?: ExternalPolicy;
552
+ /** UNIFIED cross-surface grain resolved by the route leg (supervisorGrainFor, fail-soft false) โ€”
553
+ * stamps the wake's platform address + P5 disclosure AND marks the session state `oeUnified`
554
+ * (the message-tool-only gate's key in notion.ts). Unset โ‡’ byte-identical per-doc path. */
555
+ readonly unified?: boolean;
556
+ /** SINGLE-WINDOW wake plumbing, minted by the route leg via {@link planNotionSingleWindow} when
557
+ * the grain says `singleWindow` AND the page passed the privacy floor: the wakeId is rendered
558
+ * into the envelope by this core; the whole object is passed through to deps.wake, whose
559
+ * wiring registers the map entry and diverts into the unified channel. Unset โ‡’ byte-identical. */
560
+ readonly window?: UnifiedWindowWake;
561
+ /**
562
+ * The ANCESTOR-TRUST verdict for this delivery's page (OPE-492), resolved ONCE by the webhook core
563
+ * and carried here for the ROUTE. Nothing in THIS module reads it. The route reads it twice:
564
+ * โ€ข the AUDIT ROW records WHICH refusal happened (`database-boundary`, `hop-limit`, `cycle`,
565
+ * `unresolvable`, `workspace-root`, `no-roots`) rather than only that the page was untrusted;
566
+ * โ€ข the SINGLE-WINDOW PRIVACY FLOOR accepts `trusted: true` as an internal signal (OPE-540 โ€”
567
+ * notion.ts `notionSingleWindowPrivacyOk`), so the floor and the memory plane agree on one
568
+ * page. The floor only READS this verdict; it never re-derives it, so it stays synchronous.
569
+ * The trust also travels on {@link NotionSupervisorDispatch.auth} as the `trust_root` attribute,
570
+ * which is what `bindNotion` binds.
571
+ *
572
+ * Unset on a caller that declares no roots โ€” the resolver returns `no-roots` with no I/O, and the
573
+ * webhook core omits the field. Both readers must treat the absent field as NOT trusted.
574
+ */
575
+ readonly trust?: NotionDeliveryTrust;
576
+ /** SINGLE-WINDOW addressed @mention (the plan's window carries `addressing:"direct-mention"`):
577
+ * render the wake as the addressed `direct-mention` variant (envelope kept, watch preamble
578
+ * swapped for the addressed one). Unset โ‡’ today's renders, byte-identical. */
579
+ readonly directMention?: boolean;
580
+ /**
581
+ * The ยง2.5 ENGAGEMENT SEAM is what admitted this delivery (OPE-1510): the page carries an
582
+ * engagement row, and this PLAIN comment is a follow-up on it. Resolved ONCE by the webhook core โ€”
583
+ * the one place that reads the registry โ€” and carried here for the ROUTE, exactly as {@link
584
+ * NotionSupervisorDispatch.trust} is.
585
+ *
586
+ * ๐Ÿ”ด IT DOES NOT CHANGE HOW THE WAKE IS JUDGED. `addressing` stays `channel-watch`, so the
587
+ * silence bar is untouched. The field has two readers, and both only STATE the enrolment:
588
+ * โ€ข the route's `notion.dispatched` row says WHAT WOKE IT (`notionWakeAddressing`,
589
+ * channels/notion-engagement.ts);
590
+ * โ€ข the WAKE ENVELOPE says it to the seat as `follow-up="thread-engaged"` (OPE-1711 โ€” it reaches
591
+ * {@link NotionSupervisorWakeInput.threadEngaged}, whose ๐Ÿ”ด carries the measurement).
592
+ * One verdict feeds both, so the row and the envelope can never disagree. Unset โ‡’ the row reports
593
+ * the classifier's own value and the envelope states no contract, byte-identical to before.
594
+ */
595
+ readonly engagedFollowUp?: boolean;
596
+ /**
597
+ * HOW THE OPERATOR'S OWN CONFIG HOLDS THIS PAGE (OPE-1510): `roster` โ€” a domain lists the page by
598
+ * id; `container` โ€” a domain reaches it by a CONTAINER entry, which is the whole workspace or
599
+ * a same-level page glob (memory/domain.ts); `none` โ€” no domain holds it. Resolved
600
+ * ONCE by the webhook core, by the same `notionPageDomain` read its addressed door gates on
601
+ * (channels/notion-scope.ts), and carried here for the ROUTE exactly as {@link
602
+ * NotionSupervisorDispatch.engagedFollowUp} is.
603
+ *
604
+ * ๐Ÿ”ด NOTHING IN THIS MODULE READS IT. It only lets the route's `notion.dispatched` row record the
605
+ * DOMAIN ORIGIN beside what woke the delivery, so a name-drop admitted by a claim is
606
+ * distinguishable from one on a page an operator listed. Unset โ‡’ the row reads `none`.
607
+ */
608
+ readonly domainVia?: NotionVia;
609
+ }
610
+
611
+ /**
612
+ * The dispatch AS THE WEBHOOK CORE BUILDS IT (OPE-596) โ€” every field of {@link
613
+ * NotionSupervisorDispatch} except that its messages carry no author tier yet (see {@link
614
+ * NotionUntieredWakeMessage}). The route leg (notion.ts) stamps the tier on the trigger and on every
615
+ * sibling, and that stamp is what turns this into a {@link NotionSupervisorDispatch} the supervisor
616
+ * core will accept. The two types are what stop the stamp being an optional step.
617
+ */
618
+ export interface NotionUntieredSupervisorDispatch extends Omit<NotionSupervisorDispatch, "trigger" | "threadComments"> {
619
+ readonly trigger: NotionUntieredWakeMessage;
620
+ readonly threadComments: readonly NotionUntieredWakeMessage[];
621
+ }
622
+
623
+ /**
624
+ * THE INGESTED SET for one Notion wake, graded for the non-member taint join (OPE-466 / OPE-516).
625
+ *
626
+ * ๐Ÿ”ด NOTION HAD NO AUTHOR LEG AT ALL BEFORE OPE-516, and it could not have had one. The join's
627
+ * `refuted` field is the SURFACE's positive refutation โ€” Slack's `is_restricted`, Teams' Entra
628
+ * `userType: "guest"` โ€” and Notion publishes NO membership fact about a comment author. So the
629
+ * question is answered from the operator's own AUTHOR-TRUST REGISTER instead
630
+ * (identity/author-trust.ts): on a workspace the operator has armed, an author they did not declare
631
+ * grades `external`. On an unarmed workspace the leg contributes nothing and this is a no-op.
632
+ *
633
+ * `refuted` is deliberately never set here. Leaving it absent is the honest statement โ€” Notion has
634
+ * no verdict to give, and a hardcoded `false` would read as "we asked and it was fine".
635
+ *
636
+ * NOTION HAS NO GROUP CONCEPT, so no memberships are contributed and a Notion declaration is
637
+ * principal-keys-only. That is a platform fact, not a gap in this function.
638
+ *
639
+ * A `self` author (the bot's own prior comment) carries no `authorId` from the webhook core, and its
640
+ * resolved tier is the machine floor, so it can never reach the register leg โ€” the join requires a
641
+ * positively-human tier.
642
+ *
643
+ * Pure and exported so the grading is testable without a store or a live wake.
644
+ */
645
+ export function notionIngestedMessages(d: {
646
+ readonly workspaceId: string | null;
647
+ readonly trigger: NotionWakeMessage;
648
+ readonly threadComments: readonly NotionWakeMessage[];
649
+ }): readonly IngestedChatMessage[] {
650
+ // No workspace id โ‡’ no tenant key can be composed โ‡’ no author question can be asked. Fail OPEN,
651
+ // matching every other carriage leg: an unnameable tenant is not evidence of anything.
652
+ const tenantKey = d.workspaceId ? `notion/${d.workspaceId}` : undefined;
653
+ const grade = (m: NotionWakeMessage): IngestedChatMessage => ({
654
+ ...(m.trust === undefined ? {} : { trust: m.trust }),
655
+ ...(tenantKey === undefined ? {} : { authorTrust: { tenantKey, ...(m.authorId === undefined ? {} : { authorId: m.authorId }) } }),
656
+ });
657
+ return [grade(d.trigger), ...d.threadComments.map(grade)];
658
+ }
659
+
660
+ /**
661
+ * THE EXTERNAL-AUTHOR DECISION for one Notion dispatch (OPE-507). Pure, exported so the three
662
+ * behaviours are testable without a store or a live wake.
663
+ *
664
+ * Notion's three answers, and why they are what they are:
665
+ * โ€ข the TRIGGER's author is excluded โ‡’ NO WAKE AT ALL (`suppressed`). That covers behaviour 2
666
+ * (ambient never triggers on their comment) and behaviour 1 (their @mention is not acted on).
667
+ * โ€ข a SIBLING's author is excluded โ‡’ the wake proceeds and that sibling's body is WITHHELD
668
+ * (behaviour 3): a permitted member's later comment still wakes the agent, and the excluded
669
+ * author's earlier comment reaches the model as the arrived-marker.
670
+ * โ€ข nothing is excluded, or the policy is `taint` โ‡’ `withheldIds` is empty and every caller path
671
+ * is byte-identical to before this existed.
672
+ *
673
+ * ๐Ÿ”ด NOTION POSTS NO PRIVATE NOTICE, AND THAT IS DELIBERATE. Behaviour 1 owes the excluded sender a
674
+ * PRIVATE notice, and Notion has no private channel back to a commenter โ€” a comment reply lands in
675
+ * the shared discussion, where everyone reading the doc sees it. Announcing "I will not act on your
676
+ * message" into a shared doc is a disclosure, not a notice, so the Notion answer is to do nothing.
677
+ * The surfaces that HAVE a private channel (a Discord DM, a Slack DM) post the notice there.
678
+ */
679
+ export function notionExternalAuthorDecision(d: {
680
+ readonly trigger: NotionWakeMessage;
681
+ readonly threadComments: readonly NotionWakeMessage[];
682
+ readonly ingested: readonly IngestedChatMessage[];
683
+ readonly policy: ExternalPolicy;
684
+ }): { readonly suppressed: boolean; readonly withheldIds: ReadonlySet<string> } {
685
+ // `ingested` is built by notionIngestedMessages in EXACTLY this order โ€” trigger first, then the
686
+ // siblings โ€” so the index join below is the same pairing the taint carriage already relies on.
687
+ const ordered = [d.trigger, ...d.threadComments];
688
+ const withheldIds = new Set<string>();
689
+ let suppressed = false;
690
+ for (const [i, m] of ordered.entries()) {
691
+ const facts = d.ingested[i];
692
+ if (facts === undefined || !withholdsBody(facts, d.policy)) continue;
693
+ if (i === 0) suppressed = true;
694
+ else withheldIds.add(m.id);
695
+ }
696
+ return { suppressed, withheldIds };
697
+ }
698
+
699
+ /**
700
+ * Route one classified comment to the doc supervisor: get-or-create the session, build + render
701
+ * the wake for that generation, deliver it, and stash the triggering discussion on the session
702
+ * state so `message.completed` replies into the right thread.
703
+ *
704
+ * A DELIVERY failure (the send itself) propagates to the caller's catch. A session that dies at
705
+ * TURN time does not: nothing here observes it, so the trigger is lost SILENTLY, and no reactive
706
+ * arm can exist (OPE-174 removed the inert one; channels/session-resume.ts holds the measurement).
707
+ * The working remedy is PRE-DISPATCH โ€” the live `getOrCreateSession` claims the OPE-599
708
+ * runtime-change rotation before this call and hands back a session the running eve build can read.
709
+ */
710
+ /** The default {@link NotionSupervisorDeps.markEngaged}: the dispatch's own facts, in the seam's
711
+ * vocabulary. One derivation, so the row this writes is the row `notion-engagement.ts` reads and
712
+ * `stand_down` marks. */
713
+ async function liveNotionEngagementMark(d: NotionSupervisorDispatch): Promise<void> {
714
+ const facts: NotionSeamFacts = {
715
+ workspaceId: d.workspaceId ?? undefined,
716
+ pageId: d.pageId,
717
+ ...(d.trigger.authorId ? { principalId: d.trigger.authorId } : {}),
718
+ ...(d.credentialId ? { credentialId: d.credentialId } : {}),
719
+ };
720
+ // The TRIGGER COMMENT's own id is the guard key: Notion redelivers a comment this route answered
721
+ // with a 500 (an uncaught throw below this mark), and a redelivered name-drop must not lift a
722
+ // stand-down made in between. See `markNotionEngaged`.
723
+ await markNotionEngaged(facts, d.trigger.id);
724
+ }
725
+
726
+ export async function handleNotionSupervisorDispatch(d: NotionSupervisorDispatch, deps: NotionSupervisorDeps): Promise<void> {
727
+ const atIso = deps.now ? deps.now() : new Date().toISOString();
728
+ // THE EXTERNAL-AUTHOR POLICY (OPE-507). Resolved once, ABOVE the session get-or-create: a
729
+ // suppressed wake must not mint a generation, touch a session or spend a rotation budget on a
730
+ // message the agent is not going to read. Absent policy โ‡’ the org default โ‡’ `taint` โ‡’ this whole
731
+ // block is inert and every line below runs exactly as it did.
732
+ const policy = d.externalPolicy ?? resolveExternalPolicy();
733
+ const ingested = notionIngestedMessages(d);
734
+ const external = notionExternalAuthorDecision({ trigger: d.trigger, threadComments: d.threadComments, ingested, policy });
735
+ if (external.suppressed) return; // the trigger's author may not task the agent โ€” no wake, no notice (see above)
736
+ // ๐Ÿ”ด THE ยง2.5 MARK (OPE-1501) โ€” an ADDRESSED wake ENROLS the page, and this is the FIRST line at
737
+ // which that is safe: the external-author decision above is Notion's author gate, and it is made
738
+ // here rather than at the ingress. `participating: true` inside the writer is the re-invitation,
739
+ // so naming the agent again clears a previous stand-down โ€” which is exactly why a suppressed
740
+ // author must never reach this line. Best-effort: the writer swallows a store failure, because
741
+ // engagement bookkeeping must never fail a turn.
742
+ if (d.addressing === "direct") await (deps.markEngaged ?? liveNotionEngagementMark)(d);
743
+ const session = await deps.getOrCreateSession(d.surfaceKey, d.pageId, atIso);
744
+ // `oeUnified` marks a session CREATED as the domain session (eve applies send-state at creation
745
+ // only, and a session's grain is fixed for its lifetime โ€” the token shape can't change mid-gen),
746
+ // so notion.ts's message-tool-only gate keys on it. Absent on the per-doc path (byte-identical).
747
+ const state: NotionSessionState = {
748
+ discussionId: d.discussionId || null,
749
+ pageId: d.pageId || null,
750
+ workspaceId: d.workspaceId,
751
+ ...(d.unified ? { oeUnified: true } : {}),
752
+ };
753
+
754
+ // The "changed since your last wake" PUSH โ€” computed ONCE from THIS session's cursor (its previous
755
+ // wake time), naming which shared-memory silos changed. Only when armed (deps.memoryDelta present)
756
+ // AND a prior wake exists (session.lastWakeAt); fail-soft (a delta failure never breaks the wake).
757
+ let memoryDelta: string | undefined;
758
+ if (deps.memoryDelta && session.lastWakeAt) {
759
+ try {
760
+ memoryDelta = (await deps.memoryDelta(session.lastWakeAt, atIso, session.token)) ?? undefined;
761
+ } catch {
762
+ memoryDelta = undefined;
763
+ }
764
+ }
765
+
766
+ const doWake = async (token: string, generation: number, tail: readonly WakeMessageInput[], catchUpUnavailable?: string) => {
767
+ const built: NormalizedWake = {
768
+ ...buildNotionSupervisorWake({
769
+ addressing: d.addressing,
770
+ pageId: d.pageId,
771
+ discussionId: d.discussionId,
772
+ trigger: d.trigger,
773
+ threadComments: d.threadComments,
774
+ engagementTier: d.engagementTier,
775
+ wakeId: d.window?.wakeId,
776
+ directMention: d.directMention,
777
+ threadEngaged: d.engagedFollowUp,
778
+ tail,
779
+ catchUpUnavailable,
780
+ }),
781
+ ...(memoryDelta ? { memoryDelta } : {}),
782
+ };
783
+ // BEHAVIOUR 3 (OPE-507) โ€” an excluded author's SIBLING comment reaches the model as the
784
+ // arrived-marker, not as text. It runs HERE, before renderWake, so the bytes never enter the
785
+ // envelope, the untrusted wrap, or the rendered context. Returns `built` by reference when
786
+ // nothing is withheld, so every existing render golden is untouched.
787
+ const nw = withholdWakeBodies(built, (m) => external.withheldIds.has(m.id));
788
+ const { message, context } = renderWake(nw, deps.now);
789
+ // OPE-466 CARRIAGE โ€” over the trigger AND every hydrated thread sibling. Notion re-sends the
790
+ // whole discussion on every wake, so a guest's earlier comment ENTERS the context on a wake it
791
+ // did not trigger; the leg keys on INGESTION, not on who spoke last, so those siblings count.
792
+ //
793
+ // ๐Ÿ”ด `ingestedAfterWithholding` IS WHY A WITHHELD BODY NEVER KEYS TAINT. A withheld sibling did
794
+ // not enter the context, so the join must not be told it did โ€” marking on it would set the bit
795
+ // on every withheld wake and stop it discriminating. On `taint` the same array passes through by
796
+ // reference and the stamp is byte-identical.
797
+ const auth = stampNonMemberChatAuth(d.auth, ingestedAfterWithholding(ingested, policy), { surfaceKey: d.surfaceKey });
798
+ await deps.wake({ token, auth, message, context: context ?? [], state, window: d.window });
799
+ };
800
+
801
+ // A wake failure PROPAGATES to the caller's catch. (OPE-174 removed a terminal-session-death
802
+ // force-rotate branch here: it could never fire โ€” a conversation-mode session PARKS at eve's
803
+ // token bound rather than failing, and a turn-time error cannot reject the awaited enqueue
804
+ // anyway. The reachable handler is core's agent/channels/session-park.ts.)
805
+ // ๐Ÿ”ด EVERY WAKE READS THE PAGE SINCE THE SEAT LAST LOOKED (OPE-1695 (b), brought to Notion by
806
+ // OPE-1726). A FRESH generation renders its carried tail, as it always did; an ONGOING generation
807
+ // reads the comments this discussion collected since its own `lastWakeAt`, through the ONE shared
808
+ // catch-up path โ€” same bounds, same admission policy, same cap as Slack's. FAIL-SOFT: the shared
809
+ // path never throws, and an unregistered fetch comes back as the UNAVAILABLE note rather than as a
810
+ // silent empty context.
811
+ const catchUp =
812
+ session.freshGeneration || !deps.catchUp
813
+ ? undefined
814
+ : await deps.catchUp({
815
+ container: d.pageId,
816
+ ...(d.discussionId ? { threadRoot: d.discussionId } : {}),
817
+ ...(d.trigger.anchorId ? { threadAnchor: d.trigger.anchorId } : {}),
818
+ sinceMs: seatCatchUpSinceMs(session.lastWakeAt, Date.parse(atIso) || Date.now(), NOTION_CATCH_UP_REACHBACK_HOURS),
819
+ untilMs: Date.parse(d.trigger.sentAt) || Date.parse(atIso) || Date.now(),
820
+ excludeId: d.trigger.id,
821
+ channelKey: d.surfaceKey,
822
+ });
823
+ await doWake(session.token, session.generation, session.freshGeneration ? session.tail : (catchUp?.messages ?? []), catchUp?.unavailable);
824
+ if (deps.touchSession) await deps.touchSession(d.surfaceKey, atIso);
825
+ if (deps.recordWake) {
826
+ // AFTER the wake landed, and only the TRIGGER โ€” never the backscroll rendered beside it. That is
827
+ // what stops a tail inheriting a tail (channels/session-tail.ts).
828
+ await deps.recordWake(
829
+ {
830
+ kind: "wake",
831
+ at: atIso,
832
+ from: d.trigger.from,
833
+ trust: d.trigger.trust,
834
+ // THE AUTHOR GRAIN (OPE-1747) โ€” `notionIngestedMessages` builds `ingested` trigger-FIRST, the
835
+ // same index pairing `notionExternalAuthorDecision` already relies on, so `[0]` is this row's
836
+ // author. Notion has no membership verdict of its own, so the OPERATOR's register query is the
837
+ // whole refutation and it must ride the row. Resolved through the one core road, off the SAME
838
+ // local the taint join above reads.
839
+ ...tailAuthorGrain(ingested[0] ?? { trust: d.trigger.trust }),
840
+ author: d.trigger.authorId ?? "unknown",
841
+ ...(d.trigger.authorId ? { authorId: d.trigger.authorId } : {}),
842
+ id: d.trigger.id,
843
+ platform: "notion",
844
+ container: d.surfaceKey,
845
+ threadRoot: d.discussionId,
846
+ text: d.trigger.text,
847
+ },
848
+ session.token,
849
+ d.surfaceKey,
850
+ );
851
+ }
852
+ }
853
+
854
+ // Re-export shouldRotate so the live wiring (notion.ts) drives get-or-create/rotate with the SAME
855
+ // pure policy Slack uses, without importing slack-events itself.
856
+ export { shouldRotate };