@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,1242 @@
1
+ // The Notion comment surface — an AMBIENT-JUDGED conversational channel. The agent is ambiently
2
+ // present on any doc it's connected to: a standing per-doc SUPERVISOR session reads EVERY comment
3
+ // and intelligently decides whether to engage, reusing OE's Slack channel-watch judge wholesale.
4
+ // Built as a custom `defineChannel` HTTP transport (eve ships no Notion channel) over Notion's
5
+ // public REST API + an integration API key. The eve-free orchestration is the tested core in
6
+ // notion-webhook.ts (classify) + notion-supervisor.ts (session + wake); this file is the thin
7
+ // route that wires the store, the domain opt-in, and the reply post-back.
8
+ //
9
+ // THE LOOP:
10
+ // POST /eve/v1/notion
11
+ // → one-time subscription HANDSHAKE ({verification_token}) is CAPTURED for the operator —
12
+ // stored in the encrypted vault, logged only as a sha256 fingerprint + a one-shot reveal
13
+ // link, NEVER as a value (OPE-195; it is the permanent HMAC key, not a bootstrap nonce)
14
+ // → every real delivery is HMAC-verified (X-Notion-Signature = sha256(rawBody) keyed by
15
+ // the verification token) — fail closed on a bad/missing signature or unset secret
16
+ // → a comment.created event is a SIGNAL ONLY (no text/mentions), so we RETRIEVE the
17
+ // comment, gate on the doc's domain having ambientSupervisor ON, then CLASSIFY it
18
+ // (self / bot / unidentified-author and unresolved-bot-id both fail closed / @mention→direct
19
+ // / else→judged), take the ONE-WAKE-PER-COMMENT guard, and dispatch to the standing doc
20
+ // supervisor under its reserved :oe-sup:g token
21
+ // → the supervisor either replies (POST /v1/comments into the SAME discussion_id) or stays
22
+ // audibly silent (a notion.no_reply audit row — parity with Slack)
23
+ // → SEPARATELY, and DISJOINTLY (OPE-189): every VERIFIED delivery is also handed to
24
+ // admitTrigger. Comments are conversation (the supervisor's, above) and STRUCTURAL changes —
25
+ // page/database/data_source created, moved, deleted, properties or schema updated — are work
26
+ // (a routine's). The partition is enforced twice, one-sidedly: the supervisor path ignores
27
+ // everything that is not comment.created, and the normalizer returns null for the whole
28
+ // comment.* family. No single delivery ever does both. See channels/notion-event-normalizer.ts
29
+ // All host-side REST calls use the CHANNEL-PLUMBING integration API key — the notion provider's
30
+ // `channel-key` credential, resolved via notion-api.ts resolveNotionCredential, stored under
31
+ // `db:notion:channel-key` (OPE-408 step 2); distinct from the work-surface `team-key` credential
32
+ // (`db:notion:team-key`) that backs the notion-rest egress connection.
33
+ // 🔴 …UNLESS A DOMAIN BINDS ITS OWN INTEGRATION (OPE-467). Then TWO rules split the calls:
34
+ // a DELIVERY and everything it triggers — the two-step fetch and the reply — runs under the
35
+ // integration whose secret VERIFIED the delivery (the HMAC-proven fact; a payload field must
36
+ // never pick the credential). Agent-initiated READS and proactive posts have no verifying
37
+ // delivery, so they run under the page's domain binding, else the org default
38
+ // (channels/notion-credentials.ts holds that decision). Notion's own share list is the fence
39
+ // either way. Inbound deliveries are verified against EVERY configured integration's secret.
40
+ //
41
+ // ─── GOVERNANCE: the accepted tier ──────────────────────────────────────────────────────
42
+ // The "doc brain" memory UX (a page that remembers every prior discussion) fully lights up only
43
+ // when the operator VOUCHES the workspace: bindNotion pins visibility=private and leaves trust
44
+ // undefined → bindFrom fails closed to `external` (shared/workspace memory gated off) unless the
45
+ // workspace is vouched (identity/trust.ts / OE_TRUST=internal). Ambient does NOT relax this —
46
+ // reading more comments never grants more trust. Unvouched, the agent still judges + replies, but
47
+ // without a shared page silo. Accountability is OE-AUDITED-ONLY: every turn (dispatch, reply, AND
48
+ // judged silence) is in OUR append-only log; Notion's own audit log is Enterprise-admin-only and
49
+ // not integration-queryable, so OE's log is the sole record. This is interim until Notion's
50
+ // waitlisted External Agents API lands (a first-class, Notion-audited agent participant with
51
+ // native HITL) — see design notes notion-surface-and-platform-research.md.
52
+ //
53
+ // Live e2e: scripts/e2e/scenarios.ts J-NOT (capability "notionAmbient" — self-skips without
54
+ // creds). It forge-replays a signed comment.created at a human-authored anchor → judged reply or
55
+ // audited silence (notion.no_reply over GET /control/v1/audit), verifies no self-loop across the
56
+ // reply, and proves reply-targeting across two discussions on one page supervisor session. Still
57
+ // needs a live Notion workspace + human anchor comments + Tommy for provisioning (see
58
+ // notion-ambient-surface-design.md Step 5 / the e2e-issue-surfaces driver investigation).
59
+ // ────────────────────────────────────────────────────────────────────────────────────────
60
+
61
+ import { defineChannel, POST, type RouteHandlerArgs } from "eve/channels";
62
+ import {
63
+ receiptEventTime,
64
+ spreadVendorTs,
65
+ appendAudit,
66
+ ingressSpendGate,
67
+ auditAmbientSkip,
68
+ deploymentOrg,
69
+ resolveWakeTrustFromContext,
70
+ finalMessageActionForTurn,
71
+ completeSupervisorTurn,
72
+ getConfig,
73
+ getSettings,
74
+ ambientSupervisorEnabled,
75
+ engagementTierFor,
76
+ resolveExternalPolicy,
77
+ externalPolicyOverrideFor,
78
+ liveMemoryDeltaDep,
79
+ getOperationalStore,
80
+ capabilitiesForPlatform,
81
+ consumeSupervisorReplyMark,
82
+ declaredTrustForKeys,
83
+ supervisorGrainFor,
84
+ mintRotationToken,
85
+ claimRuntimeRotation,
86
+ stampMintedRuntime,
87
+ grainUsesUnifiedWindow,
88
+ divertInboundToUnifiedWindow,
89
+ healPrunedDeploymentStrand,
90
+ captureHandshakeSecret,
91
+ admitTrigger,
92
+ NOTION_HANDSHAKE_REF_NAMESPACE,
93
+ getOperatorSecret,
94
+ type Domain,
95
+ type SupervisorGrain,
96
+ type SurfaceTrust,
97
+ carryTailForward,
98
+ type WakeMessageInput,
99
+ recordWakeTail,
100
+ postHostNotice,
101
+ detectBridgeableInputRequest,
102
+ renderQuestionForSurface,
103
+ QUESTION_ANSWERED_AUDIT_ACTION,
104
+ QUESTION_ANSWER_DROPPED_AUDIT_ACTION,
105
+ seatCatchUp,
106
+ dispatchNativeWakeWithRetry,
107
+ readCappedBody,
108
+ } from "@open-everywhere/core/agent/adapter-api.js";
109
+ import {
110
+ forgetNotionQuestion,
111
+ rememberNotionQuestion,
112
+ resolveNotionInboundAnswer,
113
+ type NotionInboundAnswer,
114
+ } from "./notion-question-return.js";
115
+ import { notionChannelCredentialFor, notionIntegrations, resolveNotionInboundSecret, NOTION_CHANNEL_CREDENTIAL } from "./notion-credentials.js";
116
+ import { resolveNotionBotUser } from "./notion-bot-user.js";
117
+ import { NOTION_EVENT_SOURCE, notionEventDeliveryId, notionTriggerScopeVerdict } from "./notion-event-normalizer.js";
118
+ import { createCommentReply, resolveNotionCredential, type ResolvedNotionCredential } from "./notion-api.js";
119
+ import { notionWorkspaceKey } from "./notion-identity.js";
120
+ import { notionTrustRoots } from "./notion-ancestry-trust.js";
121
+ import { handleAcceptedNotionDelivery, handleNotionDelivery, type NotionDeliveryOutcome, type NotionReplyTarget, type NotionSessionState } from "./notion-webhook.js";
122
+ import { acknowledgeNotionIngress, NOTION_INGRESS_MAX_BODY_BYTES } from "./notion-ingress.js";
123
+ import {
124
+ acceptNotionIngressDrainRequest,
125
+ createNotionCommentDispatchGuard,
126
+ notionIngressDrainAvailable,
127
+ NOTION_INGRESS_DRAIN_PATH,
128
+ postNotionIngressDrain,
129
+ settleNotionIngressDrain,
130
+ type AcceptedNotionIngressDrain,
131
+ type NotionIngressDisposition,
132
+ } from "./notion-ingress-drain.js";
133
+ import {
134
+ createNotionWebhookTiming,
135
+ NOTION_WEBHOOK_TIMING_ACTION,
136
+ runNotionWebhookTimed,
137
+ type NotionWebhookTimingDetail,
138
+ type NotionWebhookTimingRecorder,
139
+ } from "./notion-webhook-timing.js";
140
+ import {
141
+ handleNotionSupervisorDispatch,
142
+ notionSupervisorToken,
143
+ planNotionSingleWindow,
144
+ shouldRotate,
145
+ type NotionSupervisorDeps,
146
+ type NotionUntieredSupervisorDispatch,
147
+ type NotionUntieredWakeMessage,
148
+ } from "./notion-supervisor.js";
149
+ import { notionDispatchJoinDetail, notionWakeAuditDetail } from "./notion-engagement.js";
150
+
151
+ const ROUTE = "/eve/v1/notion";
152
+
153
+ function log(msg: string): void {
154
+ console.log(`[open-everywhere:notion] ${msg}`);
155
+ }
156
+
157
+ // The bot's own Notion user — its id (the loop guard's self-identity) AND its display name (the
158
+ // judge's name-drop input: a person typing the bot's name is a STRONG signal to the channel-watch
159
+ // preamble's "names you", NOT a bypass — the wake stays judged). An integration's bot-user is
160
+ // stable, so resolve once (env overrides NOTION_BOT_USER_ID / NOTION_BOT_NAME win for tests /
161
+ // deploys that know them — but the ID override covers the ORG-DEFAULT `channel-key` credential ONLY,
162
+ // because two Notion integrations never share a bot user and one pinned id standing for a sibling's
163
+ // would reopen the OPE-1921 cross-integration loop; NOTION_BOT_NAME stays unscoped, being a display
164
+ // name and never an identity) and cache both. Best-effort: an unresolved id makes classify fail CLOSED
165
+ // (nothing dispatches — the hardened loop guard) rather than crash; an unresolved name just means
166
+ // the judge relies on the comment text alone (it already contains the name).
167
+ // 🔴 THE RESOLVER AND ITS PER-CREDENTIAL CACHE MOVED TO `notion-bot-user.ts` (OPE-1857), because the
168
+ // SEAT CATCH-UP needs the same identity: the seat's own previous reply sits inside the window it
169
+ // reads, and Notion's comment payload cannot tell our reply from a person's by shape. Two copies of
170
+ // one identity would be the class that ticket closes.
171
+
172
+ /** Reset the cached bot users (tests only) — re-exported at its historical name. */
173
+ export { _resetNotionBotUserCache } from "./notion-bot-user.js";
174
+
175
+ // The domain registry, fail-soft. Every OPE-467 credential decision reads it, and a config-load
176
+ // error must degrade to "no domain binds anything" (⇒ the org default everywhere) rather than throw
177
+ // inside a webhook — the same fail-soft posture notionAmbientEnabled / notionGrainFor already take.
178
+ function notionDomains(): readonly Domain[] {
179
+ try {
180
+ return getConfig().loadDomains();
181
+ } catch {
182
+ return [];
183
+ }
184
+ }
185
+
186
+ /**
187
+ * The Notion channel credential for an AGENT-INITIATED call on a page — the page's domain binding,
188
+ * else the org default (channels/notion-credentials.ts holds the decision itself). This is the
189
+ * fallback rule only: a delivery and its reply carry the VERIFYING integration's credential
190
+ * instead (notion-webhook.ts selects it once; the reply arms read the carried id).
191
+ */
192
+ function notionCredentialForPage(workspaceId: string | undefined, pageId: string | undefined): string {
193
+ return notionChannelCredentialFor(workspaceId, pageId, notionDomains()) ?? NOTION_CHANNEL_CREDENTIAL;
194
+ }
195
+
196
+ /**
197
+ * One integration's INBOUND signing key, by operator-secret name. Resolved through
198
+ * `getOperatorSecret` so a value stored with `oe secret set` wins over an env var of the same name
199
+ * (the ruled OPE-408 order) — this is the one call that moved `NOTION_WEBHOOK_SECRET` off a bare
200
+ * `process.env` read. Undefined when nothing is stored under either home: an unconfigured
201
+ * integration contributes no key, and a delivery it would have signed simply fails the gate.
202
+ *
203
+ * The report-or-stay-silent decision lives in channels/notion-credentials.ts
204
+ * (resolveNotionInboundSecret), where it is testable without a store — see its header for why the
205
+ * error CLASS, not the message, is the seam.
206
+ */
207
+ function notionInboundSecret(name: string): Promise<string | undefined> {
208
+ return resolveNotionInboundSecret(name, getOperatorSecret, log);
209
+ }
210
+
211
+ // Is the ambient supervisor enabled for a Notion doc's surfaceKey? Domain-level opt-in
212
+ // (ambientSupervisorEnabled, default OFF). Fail-closed: any config-load error → OFF.
213
+ function notionAmbientEnabled(surfaceKey: string): boolean {
214
+ try {
215
+ return ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains());
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+
221
+ // UNIFIED cross-surface grain (channels/unified-supervisor.ts) — the one hop the session deps +
222
+ // wake stamp share (the Slack reference's `grainFor`). FAIL-SOFT to the per-container grain (the
223
+ // byte-identical path) on any config-load error.
224
+ function notionGrainFor(surfaceKey: string): SupervisorGrain {
225
+ try {
226
+ return supervisorGrainFor(surfaceKey, getConfig().loadDomains());
227
+ } catch {
228
+ return { unified: false, registryKey: surfaceKey };
229
+ }
230
+ }
231
+
232
+ /**
233
+ * The Notion arm of the SINGLE-WINDOW PRIVACY FLOOR — the one decision that admits a page into the
234
+ * shared cross-surface window. Pure and SYNCHRONOUS: every input is a fact the pipeline already
235
+ * resolved, so no network call enters this gate. It holds the same role as the Discord and Teams
236
+ * arms (discordSingleWindowPrivacyOk / teamsSingleWindowPrivacyOk) and is a unit-tested truth table
237
+ * the same way. It takes a `declared` resolver where those take a boolean `vouched` one, because
238
+ * this floor must tell a `public` DEMOTION apart from "nothing declared" — see below.
239
+ *
240
+ * THREE POSITIVE SIGNALS, and each one is an OPERATOR DECLARATION. None is an inference from a
241
+ * Notion API field: a page's `public_url` is null for the common guest-shared vector, so a floor
242
+ * that read it would leak private cross-surface context to an external guest.
243
+ * 1. `OE_TRUST=internal`, the blanket switch for an internal-only deployment;
244
+ * 2. the per-surface VOUCH (identity/trust.ts) — an `internal` declaration on the workspaceKey
245
+ * `notion/<ws>` or on the full surfaceKey `notion/<ws>/<page>`, most-specific-wins;
246
+ * 3. the ANCESTOR-TRUST verdict for this delivery (OPE-492): the page sits under a page the
247
+ * operator declared a TRUST ROOT.
248
+ *
249
+ * 🔴 SIGNAL 3 IS HANDED DOWN, NEVER RE-DERIVED (OPE-540). The webhook core walks the parent chain
250
+ * ONCE per delivery, under the credential that verified it, and the dispatch carries the verdict
251
+ * here — the same value the `notion.dispatched` audit row records. The floor only reads it, so the
252
+ * floor stays a synchronous predicate.
253
+ *
254
+ * 🔴 FAIL-CLOSED ON ABSENCE. `trust` is undefined on the `no-roots` verdict, because the webhook
255
+ * core omits the field there. `trusted` is false on every other refusal. Both read as "not
256
+ * internal": no throw, no pass.
257
+ *
258
+ * 🔴 A `public` DECLARATION DEMOTES ABOVE THE TRUST-ROOT VERDICT, as it does on the binding plane:
259
+ * identity/binding.ts orders `declaredExternal ?? raw.trust ?? … ?? vouched`, so `bindFrom` puts the
260
+ * operator's `public` carve-out above the walk's `internal`. A page declared `public` inside a
261
+ * trusted subtree keeps the shared org brain switched off, and this floor keeps it out of the shared
262
+ * window for the same reason.
263
+ *
264
+ * ⚠️ THE TWO PLANES STILL DIVERGE UNDER `OE_TRUST=internal`. The blanket switch is this floor's FIRST
265
+ * leg, above the demotion — exactly as on the Discord and Teams floors — while on the binding plane
266
+ * `local.trust` sits BELOW the demotion. So under the blanket, a `public`-declared page is admitted
267
+ * to the shared window while its org-brain binding stays external. Pre-existing admission-gate
268
+ * behaviour, kept deliberately for cross-platform consistency (docs/how-it-works/identity.md).
269
+ */
270
+ export function notionSingleWindowPrivacyOk(
271
+ input: {
272
+ /** `notion/<ws>` — a workspace-level declaration covers every page in the workspace. */
273
+ readonly workspaceKey: string;
274
+ /** `notion/<ws>/<page>` — a page-level declaration narrows to one page and beats the workspace. */
275
+ readonly surfaceKey: string;
276
+ /** The ancestor-walk verdict this delivery carries. Undefined ⇒ the operator declared no roots. */
277
+ readonly trust?: { readonly trusted: boolean };
278
+ },
279
+ deps: {
280
+ /** The declaration resolver, injected by tests. Returns the most-specific declared value, or
281
+ * undefined when the operator declared nothing for this page. */
282
+ readonly declared?: (workspaceKey: string, surfaceKey: string) => SurfaceTrust | undefined;
283
+ readonly trustEnv?: string | undefined;
284
+ } = {},
285
+ ): boolean {
286
+ const trustEnv = "trustEnv" in deps ? deps.trustEnv : process.env.OE_TRUST;
287
+ if (trustEnv === "internal") return true; // the blanket internal-only-deploy switch
288
+ const declared = (deps.declared ?? declaredTrustForKeys)(input.workspaceKey, input.surfaceKey);
289
+ if (declared === "public") return false; // the DEMOTION — above the trust-root verdict
290
+ if (declared === "internal") return true; // the per-surface vouch
291
+ return input.trust?.trusted === true; // the resolved trust-root verdict (OPE-540)
292
+ }
293
+
294
+ // Per-session state: the discussion thread + page/workspace so message.completed can post the
295
+ // reply back into the SAME thread (Notion's create-comment targets discussion_id).
296
+ type NotionState = NotionSessionState;
297
+
298
+ /**
299
+ * Live supervisor deps (mirrors slack-events.ts liveSupervisorDeps): create-or-fetch / rotate /
300
+ * force-rotate over the (org, surfaceKey) channel_session registry with the reserved :oe-sup:g
301
+ * token, and a `wake` that continues the session under that token. org is the deployment org (a
302
+ * deployment fact — never crosses the eve-free core boundary). Rotation reuses shouldRotate AS-IS.
303
+ * A rotation carries the OUTGOING generation's recorded TAIL forward (OPE-794).
304
+ */
305
+ // Exported (not just internal to the route) so a test can drive the `wake` leg directly — the SAME
306
+ // reason liveGitHubSupervisorDeps/liveLinearSupervisorDeps are testable without a live webhook.
307
+ // `respond` is the OPE-1305 answer leg, and it is OPTIONAL for one reason: it is the only primitive
308
+ // that reaches a delegated worker, so a caller that cannot supply it has no leg to run. Absent ⇒ the
309
+ // decision is not even attempted — no store read, and no admission call that could write a refusal
310
+ // row against a person whose answer could never have been delivered anyway. The live route supplies
311
+ // it; a test driving the send leg alone need not.
312
+ export function liveNotionSupervisorDeps(
313
+ send: SendFn,
314
+ surfaceKey: string,
315
+ to?: RouteHandlerArgs["to"],
316
+ respond?: RespondFn,
317
+ timing?: NotionWebhookTimingRecorder,
318
+ ): NotionSupervisorDeps {
319
+ const org = deploymentOrg();
320
+ const store = getOperationalStore();
321
+ // UNIFIED re-key (the Slack reference's exact shape, slack-events.ts liveSupervisorDeps): when
322
+ // the page's domain has opted in, the registry ROW is the synthetic `oe-domain::<name>` key and
323
+ // the token is `oe-dom:<name>:oe-sup:g<gen>` — one row + one token per generation for the whole
324
+ // team. Flag-off, notionGrainFor returns the per-doc grain + token — byte-identical.
325
+ return {
326
+ async getOrCreateSession(surfaceKey, pageId, atIso) {
327
+ const grain = notionGrainFor(surfaceKey);
328
+ // OPE-599: claim the runtime-change rotation BEFORE the row read, and unconditionally (the
329
+ // mint arm must consume it too). Why, and why here: channels/runtime-rotation.ts.
330
+ const runtimeChanged = await claimRuntimeRotation({ org, registryKey: grain.registryKey, surfaceKey, domainName: grain.domainName, atIso });
331
+ const existing = await store.getChannelSession(org, grain.registryKey);
332
+ if (!existing) {
333
+ const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, 1, () => notionSupervisorToken(pageId, 1)), atIso);
334
+ // OPE-1821: stamp the pair onto the row this build just minted. Without it the seat's next
335
+ // wake reads NULL, takes the ADOPTED arm and never rotates for the deploy it crossed.
336
+ await stampMintedRuntime({ org, registryKey: grain.registryKey, surfaceKey, domainName: grain.domainName, atIso });
337
+ return { token: row.token, generation: row.generation, freshGeneration: true, tail: [] as readonly WakeMessageInput[] };
338
+ }
339
+ if (shouldRotate(existing, atIso, { runtimeChanged })) {
340
+ const gen = existing.generation + 1;
341
+ // OPE-794: read the OUTGOING generation's recorded tail BEFORE minting its successor.
342
+ const tail = await carryTailForward(org, grain.registryKey, existing.token);
343
+ const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, gen, () => notionSupervisorToken(pageId, gen)), atIso);
344
+ return { token: row.token, generation: row.generation, freshGeneration: true, tail };
345
+ }
346
+ // A resume whose first wake never delivered (first_wake_at NULL) is still "fresh" (H7c
347
+ // self-heal), so the next wake re-carries backscroll. Its tail is empty — carried and cleared
348
+ // at the rotation that minted this row.
349
+ const fresh = existing.firstWakeAt == null;
350
+ // On a NON-fresh generation the row's lastWakeAt IS the previous wake (touchSession advances it
351
+ // only at dispatch end, AFTER this read) — the memory-delta cursor. Fresh ⇒ no prior wake ⇒ omit.
352
+ return { token: existing.token, generation: existing.generation, freshGeneration: fresh, tail: [] as readonly WakeMessageInput[], ...(fresh ? {} : { lastWakeAt: existing.lastWakeAt }) };
353
+ },
354
+ touchSession: (surfaceKey, atIso) => store.touchChannelSession(org, notionGrainFor(surfaceKey).registryKey, atIso),
355
+ // THE SINCE-LAST-WAKE CATCH-UP (OPE-1726), through the shared path core holds. Notion's own fetch
356
+ // is registered at bundle registration; the admission policy is the DOMAIN's, resolved by the
357
+ // shared road itself from the request's own channel key (OPE-1857). No `trustFor`: this surface
358
+ // has no synchronous roster probe for a comment author, so the shared core's FAIL-CLOSED
359
+ // per-sender default applies — a human sibling reads `unauth` and stays fenced, the honest floor
360
+ // for an author the deployment cannot vouch at wake time.
361
+ catchUp: (req) => seatCatchUp("notion", req),
362
+ // OPE-794 — file this wake on the live generation's tail ring. Same key derivation as the
363
+ // rotation read and the spend counter, so all three target one row.
364
+ recordWake: (entry, token, surfaceKey) => recordWakeTail(org, notionGrainFor(surfaceKey).registryKey, token, entry),
365
+ // The "changed since your last wake" memory-delta PUSH — armed ONLY under the unified org gate
366
+ // (undefined otherwise ⇒ dispatch never computes ⇒ byte-identical). Keyed by the page's surface
367
+ // so the delta names ONLY silos this session reads.
368
+ memoryDelta: liveMemoryDeltaDep(org, surfaceKey),
369
+ now: () => new Date().toISOString(),
370
+ async wake({ token, auth, message, context, state, window }) {
371
+ // SINGLE-WINDOW diversion (channels/unified-window.ts, mirroring linear.ts's wake dep): a
372
+ // window-carrying wake goes through eve's cross-channel `to(...).send(...)` into the synthetic
373
+ // `unified` channel, so its effective token is `unified:<raw>`, shared with the domain's
374
+ // Slack/GitHub/Linear wakes: ONE context window. The unified channel's `receive` hook registers
375
+ // the wakeId→origin map entry (durably, BEFORE the send) and re-checks the gates
376
+ // fail-closed. A failure propagates to the route's dispatch (fail-closed: never delivered
377
+ // without a routable reply target). This IS the native-session suppression on Notion —
378
+ // there is no separate per-doc mention channel to stand down; the one dispatch path either
379
+ // sends here (native) or diverts (window), off the one seam verdict (planNotionSingleWindow).
380
+ if (window) {
381
+ // A window means the wake carries a `direct-mention`/wake-id envelope AND the reply target
382
+ // must be registered in the map (divertToUnifiedWindow → unified.receive). Without a
383
+ // cross-channel `to` we CANNOT register it, so a native send would post an
384
+ // unresolvable wake-id — refuse VISIBLY instead (the route always supplies `to`, so this
385
+ // is defense-in-depth, never reached in prod).
386
+ if (!to) throw new Error("notion single-window wake has no cross-channel dispatch — refusing (would post an unregistered wake-id)");
387
+ // THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719) — the INBOUND variant, the one Slack's
388
+ // front route has always used. A wake landing in a discussion a live task worker or thread
389
+ // seat OWNS is dispatched to that owner's token instead of the standing seat's. Notion is
390
+ // thread-first: every wake arrives scoped to one discussion, so every wake on this leg
391
+ // consults ownership. Flag-off, and on every unowned discussion, this is a byte-identical
392
+ // passthrough to `divertToUnifiedWindow`.
393
+ const divert = async () => {
394
+ // Keep the core divert explicitly awaited at the provider boundary. Besides preserving
395
+ // rejection timing inside this phase, this is the source-level proof behind Notion's
396
+ // `owned-thread-ingress` capability declaration.
397
+ await divertInboundToUnifiedWindow(to, {
398
+ message,
399
+ context: [...context],
400
+ token,
401
+ window,
402
+ surfaceKey,
403
+ auth: auth as unknown as { attributes?: Record<string, unknown> },
404
+ });
405
+ };
406
+ if (timing) await timing.measure("eveSend", divert);
407
+ else await divert();
408
+ return;
409
+ }
410
+ // ── THE ANSWER LEG (OPE-1305) ────────────────────────────────────────────────────────────
411
+ // 🔴 `respond` IS THE ONLY PRIMITIVE THAT REACHES A DELEGATED WORKER. eve routes an answer to a
412
+ // child solely through `payload.inputResponses` entries whose `requestId` is in the parent
413
+ // session's proxy map; a plain `send` falls to `forSelf` — the parent — every time. So this
414
+ // branch is the whole return leg on the native route, and everything else here was already
415
+ // present. channels/notion-question-return.ts owns the decision and fails soft in one
416
+ // direction: no row, another discussion, a refused answerer ⇒ an ordinary send.
417
+ //
418
+ // THE ANSWER TEXT IS THE RENDERED WAKE, deliberately — byte-exactly what eve consumed as the
419
+ // answer before this branch existed, so the change is WHERE the comment is addressed, not what
420
+ // the model reads. It keeps the envelope's provenance fence around the human's words.
421
+ //
422
+ // NEVER DROP A COMMENT. A `respond` that throws falls through to the ordinary send with the row
423
+ // LEFT IN PLACE, so the discussion is still heard and the question is still answerable.
424
+ if (respond !== undefined) {
425
+ const answer = await resolveNotionInboundAnswer({ org, bareToken: token, auth });
426
+ if (answer) {
427
+ const deliverAnswer = () => deliverNotionAnswer({ answerOrg: org, token, auth, message, context, answer, respond, surfaceKey });
428
+ if (timing ? await timing.measure("eveSend", deliverAnswer) : await deliverAnswer()) return;
429
+ }
430
+ }
431
+ try {
432
+ const workspace = surfaceKey.split("/")[1] ?? "unknown";
433
+ const dispatchWake = () => dispatchNativeWakeWithRetry(
434
+ () => send({ message, context: [...context] }, { auth, continuationToken: token, state }),
435
+ { platform: "notion", workspace, surfaceKey },
436
+ );
437
+ if (timing) await timing.measure("eveSend", dispatchWake);
438
+ else await dispatchWake();
439
+ } catch (err) {
440
+ // OPE-971: the NATIVE (flag-off) send has no window and no divert. `notionGrainFor(surfaceKey)
441
+ // .registryKey === surfaceKey` flag-off (the exact row `getOrCreateSession` above already
442
+ // rotates), so it's passed straight through — see the GitHub/Linear/Slack siblings.
443
+ await healPrunedDeploymentStrand({
444
+ token,
445
+ registryKey: surfaceKey,
446
+ surfaceKey,
447
+ workspace: surfaceKey.split("/")[1] ?? "unknown",
448
+ platform: "notion",
449
+ atIso: new Date().toISOString(),
450
+ err,
451
+ });
452
+ throw err;
453
+ }
454
+ },
455
+ };
456
+ }
457
+
458
+ /** The channel `send` shape this route uses (structural — avoids importing eve's SendPayload, and
459
+ * since eve 0.31 there is no public `SendFn` to import at all: the address moved onto
460
+ * `from(address)` and `context` moved from the payload into the send options. {@link notionSendFrom}
461
+ * is the one adapter that bridges the two shapes). */
462
+ type SendFn = (
463
+ payload: { message: string; context?: string[] },
464
+ opts: { auth: unknown; continuationToken: string; state: NotionState },
465
+ ) => Promise<void>;
466
+
467
+ /** Adapt eve 0.31's `from(address).send(message, options)` to {@link SendFn}. */
468
+ function notionSendFrom(from: RouteHandlerArgs<NotionState>["from"]): SendFn {
469
+ return async (payload, opts) => {
470
+ await from(opts.continuationToken).send(payload.message, {
471
+ auth: opts.auth,
472
+ state: opts.state,
473
+ ...(payload.context && payload.context.length > 0 ? { context: payload.context } : {}),
474
+ } as never);
475
+ };
476
+ }
477
+
478
+ /** The ANSWER delivery (OPE-1305) — `from(token).respond([{requestId, text}])`, the one primitive
479
+ * that reaches a delegated worker. Structural, for the same reason {@link SendFn} is: eve exports no
480
+ * public type for it. */
481
+ type RespondFn = (input: { requestId: string; text: string }, opts: { auth: unknown; continuationToken: string; context?: readonly string[] }) => Promise<void>;
482
+
483
+ /** Adapt eve's `from(address).respond(responses, options)` to {@link RespondFn}. */
484
+ function notionRespondFrom(from: RouteHandlerArgs<NotionState>["from"]): RespondFn {
485
+ return async (input, opts) => {
486
+ await (from(opts.continuationToken) as unknown as { respond: (r: readonly { requestId: string; text: string }[], o: unknown) => Promise<unknown> }).respond(
487
+ [{ requestId: input.requestId, text: input.text }],
488
+ {
489
+ auth: opts.auth,
490
+ // The side-channel context rides the answer for the same reason it rides the send: a comment
491
+ // that happens to resolve a question must not lose the context every other wake carries.
492
+ ...(opts.context && opts.context.length > 0 ? { context: [...opts.context] } : {}),
493
+ },
494
+ );
495
+ };
496
+ }
497
+
498
+ type NotionWebhookRouteArgs = Pick<RouteHandlerArgs<NotionState>, "from" | "waitUntil" | "to" | "attachSession">;
499
+
500
+ /** Request-wide timing finalizer: early credential reads, delivery, and late route work share one
501
+ * emit-once failure boundary, while the wrapped handler keeps the route's original semantics. */
502
+ function timedNotionWebhookRoute(
503
+ handler: (
504
+ req: Request,
505
+ args: NotionWebhookRouteArgs,
506
+ timing: NotionWebhookTimingRecorder,
507
+ finishTiming: (outcome: string) => void,
508
+ ) => Promise<Response>,
509
+ ): (req: Request, args: NotionWebhookRouteArgs) => Promise<Response> {
510
+ return async (req, args) => {
511
+ const timing = createNotionWebhookTiming();
512
+ const emit = (detail: NotionWebhookTimingDetail): void => {
513
+ try {
514
+ args.waitUntil(
515
+ appendAudit({
516
+ org: deploymentOrg(),
517
+ platform: "notion",
518
+ workspace: "unknown",
519
+ surface: "unknown",
520
+ action: NOTION_WEBHOOK_TIMING_ACTION,
521
+ detail,
522
+ }).catch(() => undefined),
523
+ );
524
+ } catch {
525
+ // Telemetry must never replace the delivery's own response or thrown failure.
526
+ }
527
+ };
528
+ return runNotionWebhookTimed(timing, emit, (finish) => handler(req, args, timing, finish));
529
+ };
530
+ }
531
+
532
+ /**
533
+ * Deliver one inbound comment as the ANSWER to the session's outstanding question, and say so in the
534
+ * audit trail. Returns whether it landed — `false` means the caller must fall through to the
535
+ * ordinary send, with the outstanding-question row deliberately LEFT IN PLACE.
536
+ *
537
+ * 🔴 THE ROW IS CONSUMED ONLY AFTER eve ACCEPTED THE DELIVERY. It is what makes the NEXT comment an
538
+ * ordinary wake again, so dropping it before the `respond` returns would leave a question answered
539
+ * and a row saying it is still open — or, worse, consume it for a delivery that never happened.
540
+ *
541
+ * 🔴 IDS, ENUMS AND COUNTS ONLY. The answer text is a person's prose in a discussion thread and is
542
+ * never audited, the same redaction rule the question side states. Both outcomes write a row;
543
+ * neither is silent.
544
+ */
545
+ async function deliverNotionAnswer(input: {
546
+ readonly answerOrg: string;
547
+ readonly token: string;
548
+ readonly auth: unknown;
549
+ readonly message: string;
550
+ readonly context: readonly string[];
551
+ readonly answer: NotionInboundAnswer;
552
+ readonly respond: RespondFn;
553
+ readonly surfaceKey: string;
554
+ }): Promise<boolean> {
555
+ const detail = {
556
+ requestId: input.answer.requestId,
557
+ floor: input.answer.floor,
558
+ chars: input.message.length,
559
+ surfaceKey: input.surfaceKey,
560
+ route: "native",
561
+ ...(input.answer.taskId !== undefined ? { taskId: input.answer.taskId } : {}),
562
+ ...(input.answer.wakeId !== undefined ? { wakeId: input.answer.wakeId } : {}),
563
+ };
564
+ // The SAME partition every other Notion row takes (`notion.dispatched` above): the workspace id
565
+ // and the PAGE, both read off the surface key `notion/<workspace>/<page>`. The full key rides the
566
+ // detail instead, so an operator can still join these rows to the dispatch that carried them.
567
+ const [, workspace = "unknown", surface = "unknown"] = input.surfaceKey.split("/");
568
+ try {
569
+ await input.respond(
570
+ { requestId: input.answer.requestId, text: input.message },
571
+ { auth: input.auth, continuationToken: input.token, context: input.context },
572
+ );
573
+ } catch (err) {
574
+ const error = (err instanceof Error ? err.message : String(err)).slice(0, 300);
575
+ log(`answer ${input.answer.requestId} could NOT be delivered (${error}) — falling back to an ordinary wake, the question stays open`);
576
+ await appendAudit({
577
+ org: input.answerOrg,
578
+ platform: "notion",
579
+ workspace,
580
+ surface,
581
+ action: QUESTION_ANSWER_DROPPED_AUDIT_ACTION,
582
+ detail: { ...detail, error },
583
+ }).catch(() => undefined);
584
+ return false;
585
+ }
586
+ await forgetNotionQuestion(input.answerOrg, input.token);
587
+ log(`answer ${input.answer.requestId} delivered to ${input.token} (floor ${input.answer.floor})`);
588
+ await appendAudit({
589
+ org: input.answerOrg,
590
+ platform: "notion",
591
+ workspace,
592
+ surface,
593
+ action: QUESTION_ANSWERED_AUDIT_ACTION,
594
+ detail,
595
+ }).catch(() => undefined);
596
+ return true;
597
+ }
598
+
599
+ function notionIngressDisposition(outcome: NotionDeliveryOutcome, eventPlaneRefused: boolean): NotionIngressDisposition {
600
+ if (outcome.action === "no-api-key" || outcome.action === "error") return "retry";
601
+ if (
602
+ eventPlaneRefused ||
603
+ outcome.action === "ambient-disabled" ||
604
+ outcome.action === "skipped-self" ||
605
+ outcome.action === "skipped-bot" ||
606
+ outcome.action === "dropped" ||
607
+ outcome.action === "unresolved-page" ||
608
+ outcome.action === "stood-down" ||
609
+ outcome.action === "scope-refused"
610
+ ) {
611
+ // These gates all precede the comment-dispatch lease. Deleting the primary row preserves the
612
+ // prior contract: a later provider redelivery may be reconsidered after config/state changes.
613
+ return "discard";
614
+ }
615
+ return "complete";
616
+ }
617
+
618
+ const notionWebhookRouteHandler = timedNotionWebhookRoute(
619
+ async (req, { from, waitUntil, to, attachSession }, timing, finishTiming) => {
620
+ const send = notionSendFrom(from);
621
+ // The OPE-1305 answer leg. Built beside `send` from the same `from`, so the two primitives
622
+ // always address the same session.
623
+ const respond = notionRespondFrom(from);
624
+ const internalDrain = new URL(req.url).pathname === NOTION_INGRESS_DRAIN_PATH;
625
+ let acceptedIngress: AcceptedNotionIngressDrain | undefined;
626
+ let rawBody: string;
627
+ let signature: string | null;
628
+ if (internalDrain) {
629
+ // Cap and authenticate the ORIGINAL stream before reading provider config or secrets. A
630
+ // reconstructed Request here would make the 16 KiB cap cosmetic after an unbounded read.
631
+ const admitted = await acceptNotionIngressDrainRequest(req, {
632
+ org: deploymentOrg(),
633
+ store: getOperationalStore(),
634
+ });
635
+ if ("response" in admitted) {
636
+ finishTiming(admitted.response.ok ? "drain-idle" : "drain-refused");
637
+ return admitted.response;
638
+ }
639
+ acceptedIngress = admitted.accepted;
640
+ rawBody = acceptedIngress.rawBody;
641
+ signature = null;
642
+ } else {
643
+ const read = await timing.measure("credential", () => readCappedBody(req, NOTION_INGRESS_MAX_BODY_BYTES));
644
+ if ("tooLarge" in read) {
645
+ finishTiming("accept-refused");
646
+ return new Response("payload too large", { status: 413 });
647
+ }
648
+ rawBody = read.body;
649
+ signature = req.headers.get("x-notion-signature");
650
+ }
651
+ // EVERY configured integration, org default first (OPE-467). One entry on a single-
652
+ // integration deployment, so the gate below does exactly what it always did.
653
+ const integrations = notionIntegrations(notionDomains());
654
+ // Resolve each SECRET NAME at most once per delivery — the same memo shape `apiKeys` below
655
+ // uses. Two integrations may legitimately share one inbound secret name (a domain that binds
656
+ // its own credential but omits `inboundSecret` keeps the deployment default), and this runs
657
+ // on an UNAUTHENTICATED POST, before the HMAC gate: without the memo each such pair costs a
658
+ // second credential-store read that an anonymous caller can drive. It cannot go below one
659
+ // read per DISTINCT name — trying every configured secret is what identifies the sender.
660
+ const secrets = new Map<string, Promise<string | undefined>>();
661
+ const inboundSecretFor = (name: string): Promise<string | undefined> => {
662
+ const hit = secrets.get(name);
663
+ if (hit) return hit;
664
+ const resolved = notionInboundSecret(name);
665
+ secrets.set(name, resolved);
666
+ return resolved;
667
+ };
668
+ const signingKeys = await timing.measure("credential", () => Promise.all(
669
+ integrations.map(async (i) => ({ credentialId: i.credentialId, secret: await inboundSecretFor(i.inboundSecret) })),
670
+ ));
671
+
672
+ if (!internalDrain) {
673
+ const admission = await acknowledgeNotionIngress(rawBody, signature, {
674
+ org: deploymentOrg(),
675
+ signingKeys,
676
+ allowUnverified: getSettings().webhooks.allowUnverifiedNotion,
677
+ drainAvailable: notionIngressDrainAvailable(),
678
+ enqueue: (org, write) => getOperationalStore().enqueueIngressDelivery(org, write),
679
+ drain: (key) => postNotionIngressDrain(key),
680
+ waitUntil,
681
+ });
682
+ if (admission.headers.get("x-oe-notion-ingress") !== "synchronous") {
683
+ finishTiming(admission.ok ? "accepted" : "accept-refused");
684
+ return admission;
685
+ }
686
+ }
687
+ // Resolve each integration's token AT MOST ONCE per delivery. The dispatch closure, the spend
688
+ // gate's refusal post and the two-step fetch all ask for the same credential id.
689
+ const credentials = new Map<string, Promise<ResolvedNotionCredential | undefined>>();
690
+ // OPE-505: the memo holds the key WITH its endpoint rules. Resolving the bare token here would
691
+ // hand every REST call below an UNFENCED fetch, because guardCredentialFetch returns the base
692
+ // fetch by identity when it is given no rules.
693
+ const credentialFor = (credentialId: string): Promise<ResolvedNotionCredential | undefined> => {
694
+ const hit = credentials.get(credentialId);
695
+ if (hit) return hit;
696
+ const resolved = resolveNotionCredential(credentialId);
697
+ credentials.set(credentialId, resolved);
698
+ return resolved;
699
+ };
700
+ // The bot user for ONE integration — its own id (the loop guard) and its own display name.
701
+ const botUserFor = (credentialId: string): Promise<{ id?: string; name?: string }> =>
702
+ resolveNotionBotUser(credentialId, () => credentialFor(credentialId))();
703
+
704
+ // Route the classified comment to the standing doc supervisor, then audit the dispatch.
705
+ // The unified grain is stamped HERE (the webhook core stays grain-free): it drives the
706
+ // wake's platform address + P5 disclosure and the session's `oeUnified` seed marker; the
707
+ // registry/token re-key lives in the deps — all off the same fail-soft grain resolution.
708
+ const dispatch = async (d: NotionUntieredSupervisorDispatch): Promise<void> => {
709
+ const grain = notionGrainFor(d.surfaceKey);
710
+ // The integration that serves THIS page (OPE-467). Taken from the DISPATCH, not re-derived:
711
+ // the webhook core selected it once for this delivery, and the spend gate's visible refusal
712
+ // post is a real Notion comment that has to go out under that same integration. Re-deriving
713
+ // it here would be a second rule that agrees by luck.
714
+ const pageCredential = await credentialFor(d.credentialId);
715
+ // SINGLE-WINDOW plan (channels/unified-window.ts via planNotionSingleWindow — the shared
716
+ // seam): on a `singleWindow` grain, resolve the PRIVACY FLOOR through the pure predicate
717
+ // above ({@link notionSingleWindowPrivacyOk}), which reads the operator-vouch register, the
718
+ // OE_TRUST blanket switch, and THIS DELIVERY'S ancestor-trust verdict. The verdict arrives
719
+ // on the dispatch (`d.trust`), already resolved by the webhook core under the credential
720
+ // that verified the delivery — so the floor stays a synchronous lookup with NO network, and
721
+ // a page that binds `internal` through a trust root now joins the same cross-surface window
722
+ // its memory already writes to (OPE-540). Fail-CLOSED: no positive signal → privacyOk=false
723
+ // → native path. Off the singleWindow grain this is a no-op — byte-identical. (`to` is
724
+ // always present on this route, so a minted wake-id is always registerable; the wake dep
725
+ // additionally refuses a window with no `to` — belt-and-braces against a dangling wake-id
726
+ // on the native send.)
727
+ const usesWindow = grainUsesUnifiedWindow(grain); // cross-surface OR solo (byte-identical flag-off)
728
+ const privacyOk =
729
+ usesWindow &&
730
+ notionSingleWindowPrivacyOk({
731
+ workspaceKey: notionWorkspaceKey(d.workspaceId ?? "unknown"),
732
+ surfaceKey: d.surfaceKey,
733
+ trust: d.trust,
734
+ });
735
+ const window = usesWindow
736
+ ? planNotionSingleWindow(true, d.addressing === "direct", privacyOk, {
737
+ discussionId: d.discussionId,
738
+ pageId: d.pageId || undefined,
739
+ workspaceId: d.workspaceId,
740
+ // Freeze the DELIVERY's integration into the origin, so the unified reply posts under
741
+ // the credential that fetched — the same id the native path carries on the auth.
742
+ credentialId: d.credentialId,
743
+ // THE FRONT-DOOR RECEIPT (OPE-1716) — a Notion delivery carries no vendor clock this
744
+ // seam can read, so the ingress row records our own instant and marks it `receipt`,
745
+ // which the split reporter renders as a LOWER BOUND and never as a measurement.
746
+ // 🔴 SAY WHAT IT IS: taken HERE, at the divert decision, not at the route's first byte
747
+ // — so on Notion the vendor→ingress segment is a floor near zero and carries no
748
+ // information about the vendor's own hop. It is the truthful floor, not a measure of it.
749
+ ...spreadVendorTs(receiptEventTime(Date.now())),
750
+ })
751
+ : undefined;
752
+ // PER-MESSAGE TRUST (identity/wake-trust.ts — audit-wedge F3): resolved off the
753
+ // SAME auth capture the session binds with (Notion's mapper leaves trust
754
+ // undefined, so the gate fail-closes external — relay — unless the operator
755
+ // vouches the workspace / OE_TRUST) + the config-as-code admin rosters. Applies
756
+ // to the trigger AND the discussion-sibling backscroll; the bot's own `self`
757
+ // comments are relay (machine-authored). Directness stays the `mention` flag —
758
+ // it no longer implies the principal tier.
759
+ const wakeCtx = { channel: { kind: "notion" }, session: { auth: { current: d.auth as unknown as { attributes?: Readonly<Record<string, string>> } } } };
760
+ // A tier above `relay` requires an IDENTIFIED HUMAN author: `from === "human"` AND a
761
+ // concrete authorId. A sibling with no author id — or a non-person/bot author, which
762
+ // notion-webhook.ts leaves id-less (F3) — resolves `relay`. isBot:false is safe here
763
+ // (the trigger passed classify's person gate; a sibling only carries an id when it's a
764
+ // person). NO principalFallback: a per-message sibling must never borrow the trigger
765
+ // author's principal id (which would render an unattributed sibling as the trigger's tier).
766
+ const trustOf = (m: NotionUntieredWakeMessage) =>
767
+ m.from === "human" && m.authorId ? resolveWakeTrustFromContext(wakeCtx, { id: m.authorId, isBot: false }) : ("relay" as const);
768
+
769
+ // SPEND CAP (pre-flight — the ONE shared gate, spend/ingress-gate.ts, the same function every
770
+ // other surface and the cross-surface divert call): an over-budget scope DECLINES the turn
771
+ // with a VISIBLE refusal, never a silent drop, before the model turn. CHECK fails OPEN; a
772
+ // definite OVER fails CLOSED unless the sender is an admin over this scope.
773
+ //
774
+ // POSITION: BEFORE handleNotionSupervisorDispatch (and its notion.dispatched audit) so a
775
+ // decline skips both. Notion has no separate native mention leg — every Notion comment wake
776
+ // flows through THIS closure — but a single-window wake still passes the divert's own gate
777
+ // afterwards (channels/unified.ts, door "window"), which is what covers the surfaces whose
778
+ // window leg bypasses their native one. Here the two simply agree.
779
+ if ((await ingressSpendGate({
780
+ bindable: wakeCtx,
781
+ door: "mention",
782
+ ...(d.auth.principalId ? { principalId: d.auth.principalId } : {}),
783
+ post: async (text) => { await createCommentReply({ discussionId: d.discussionId, text, apiKey: pageCredential?.apiKey ?? "", endpoints: pageCredential?.endpoints }); },
784
+ })) === "declined") {
785
+ return; // drop — no model turn
786
+ }
787
+
788
+ await handleNotionSupervisorDispatch(
789
+ {
790
+ ...d,
791
+ unified: grain.unified,
792
+ // SINGLE-WINDOW plumbing: the core renders the wake-id into the envelope and the wake
793
+ // dep diverts the delivery into the unified channel. `directMention` is the SEAM's
794
+ // addressed verdict (window.addressing) — the same one decision drives the divert,
795
+ // the addressed render, and the owed-reply stamp (which since OPE-1982 classifies a
796
+ // dropped turn rather than selecting a delivery route).
797
+ window,
798
+ ...(window?.addressing === "direct-mention" ? { directMention: true } : {}),
799
+ trigger: { ...d.trigger, trust: trustOf(d.trigger) },
800
+ threadComments: d.threadComments.map((m) => ({ ...m, trust: trustOf(m) })),
801
+ // THE EXTERNAL-AUTHOR POLICY (OPE-507), resolved HERE because the supervisor core stays
802
+ // config-free — the same split `engagementTier` uses. Org default from the settings
803
+ // block, domain override from `Domain.externalPolicy`, applied field by field.
804
+ externalPolicy: resolveExternalPolicy(externalPolicyOverrideFor(d.surfaceKey, getConfig().loadDomains())),
805
+ },
806
+ liveNotionSupervisorDeps(send, d.surfaceKey, to, respond, timing),
807
+ );
808
+ // LOUD, because the alternative is silence (OPE-492). A declared trust root that Notion
809
+ // serves as neither a page nor a block matches nothing the walk ever sees, so it grants
810
+ // exactly nothing — and a config line that quietly grants nothing is the failure this warning
811
+ // exists to end. The usual cause is a TEAMSPACE id, which is a 32-hex uuid and therefore
812
+ // indistinguishable from a page id at config time.
813
+ if (d.trust?.unreachableRoots && d.trust.unreachableRoots.length > 0) {
814
+ console.warn(
815
+ `[open-everywhere:notion] declared trust root(s) ${d.trust.unreachableRoots.join(", ")} resolve as neither a page nor a block under integration "${d.credentialId}" — such a root can never match, so it grants nothing. If it is a TEAMSPACE id, declare the top PAGE of the teamspace instead.`,
816
+ );
817
+ }
818
+ await timing.measure("finalAudit", () => appendAudit({
819
+ org: deploymentOrg(),
820
+ platform: "notion",
821
+ workspace: d.workspaceId ?? "unknown",
822
+ surface: d.pageId || d.discussionId || "unknown",
823
+ principalId: d.auth.principalId || undefined,
824
+ action: "notion.dispatched",
825
+ detail: {
826
+ // WHAT WOKE IT and HOW THE PAGE IS DECLARED (OPE-1510, notion-engagement.ts).
827
+ // `addressing` is not what the classifier called it — an engaged follow-up reads
828
+ // `thread-follow` here while the turn stays judged. `via` is the domain origin the
829
+ // addressed door gated on, so a name-drop admitted by a `container` claim is
830
+ // distinguishable from one on a `roster` page an operator listed.
831
+ ...notionWakeAuditDetail(d.addressing, d.engagedFollowUp, d.domainVia),
832
+ // THE EXACT-JOIN IDS (OPE-1907). Ids only — never a byte of the comment. They are what
833
+ // lets ONE delivery be joined to the turn it woke: `deliveryId` separates two
834
+ // deliveries of the same comment, and `wakeId` is the key `wake.ingress` and
835
+ // `wake.turn_start` already carry, so the row bridges the front door to the session.
836
+ ...notionDispatchJoinDetail({
837
+ deliveryId: notionEventDeliveryId(rawBody),
838
+ commentId: d.trigger.id,
839
+ ...(window ? { wakeId: window.wakeId } : {}),
840
+ }),
841
+ discussionId: d.discussionId,
842
+ ...(window ? { singleWindow: true } : {}),
843
+ // The ancestor-trust verdict (OPE-492), when roots are declared. The REASON is the point:
844
+ // `database-boundary` / `hop-limit` / `cycle` / `unresolvable` / `workspace-root` each say
845
+ // a different thing about why a page did not inherit trust, and a row that recorded only
846
+ // "untrusted" would send an operator to look at the wrong thing. `unreachableRoots` names a
847
+ // declared root Notion serves as neither page nor block — the teamspace-id case.
848
+ ...(d.trust
849
+ ? {
850
+ trust: {
851
+ trusted: d.trust.trusted,
852
+ reason: d.trust.reason,
853
+ hops: d.trust.hops,
854
+ ...(d.trust.matchedRoot ? { matchedRoot: d.trust.matchedRoot } : {}),
855
+ ...(d.trust.unreachableRoots ? { unreachableRoots: d.trust.unreachableRoots } : {}),
856
+ },
857
+ }
858
+ : {}),
859
+ },
860
+ }));
861
+ };
862
+
863
+ const deliveryDeps = {
864
+ signingKeys,
865
+ allowUnverified: getSettings().webhooks.allowUnverifiedNotion,
866
+ credentialFor,
867
+ credentialForPage: (workspaceId, pageId) => notionChannelCredentialFor(workspaceId, pageId, notionDomains()),
868
+ // The registry the OPE-502 signer fence reads (step 8b). Passed as data, not as a verdict —
869
+ // the fence itself lives in the tested core so no wiring can weaken it.
870
+ domains: notionDomains(),
871
+ // The operator's declared TRUST ROOTS (OPE-492) — the `subtree: true` lines of the shared
872
+ // trust registry, handed to this bundle at boot (OPE-539). Empty on a deployment that
873
+ // declares none, which makes step 10b a no-op with no API call.
874
+ trustRoots: notionTrustRoots(),
875
+ resolveBotUserId: async (credentialId) => (await botUserFor(credentialId)).id,
876
+ resolveBotName: async (credentialId) => (await botUserFor(credentialId)).name,
877
+ ambientEnabled: notionAmbientEnabled,
878
+ // The ambient-everywhere calibration dial (explicit domain ← org ← platform default);
879
+ // handleNotionDelivery fail-softs any throw here to neutral.
880
+ engagementTier: (surfaceKey) => engagementTierFor(surfaceKey, getConfig().loadDomains()),
881
+ timing,
882
+ dispatch,
883
+ ...(acceptedIngress
884
+ ? {
885
+ dispatchGuard: createNotionCommentDispatchGuard({
886
+ org: deploymentOrg(),
887
+ store: getOperationalStore(),
888
+ }),
889
+ }
890
+ : {}),
891
+ // THE STOP LEG's cancel door (OPE-989) — eve's channel-agnostic `attachSession`, the same
892
+ // one github.ts's wrapRoute and teams-stop.ts's route pass to their own stop arms.
893
+ attachSession,
894
+ } satisfies Parameters<typeof handleNotionDelivery>[2];
895
+ let outcome: NotionDeliveryOutcome;
896
+ try {
897
+ outcome = acceptedIngress
898
+ ? await handleAcceptedNotionDelivery(rawBody, acceptedIngress.signerCredentialId, deliveryDeps)
899
+ : await handleNotionDelivery(rawBody, signature, deliveryDeps);
900
+ } catch (err) {
901
+ if (acceptedIngress) {
902
+ await settleNotionIngressDrain(acceptedIngress, "retry", {
903
+ org: deploymentOrg(),
904
+ store: getOperationalStore(),
905
+ });
906
+ }
907
+ throw err;
908
+ }
909
+
910
+ // ─── THE EVENT-PLANE LEG (OPE-189) ──────────────────────────────────────────────────────
911
+ // The SECOND consumer of this one delivery, and it is DISJOINT from the supervisor above by
912
+ // event type: `comment.*` is conversation (handled above; the normalizer returns null for it,
913
+ // so it can never fire a routine) and `page.*`/`database.*`/`data_source.*` is work (ignored
914
+ // above; only routines see it). The rule lives in ONE place — notion-event-normalizer.ts —
915
+ // rather than being re-spelled here as a second event-type list that could drift out of step
916
+ // with it; this route hands over EVERY verified delivery and lets the normalizer decide.
917
+ //
918
+ // 🔴 GATED ON `verified`, NOT on the gate having passed: the unverified-hatch acceptance
919
+ // (`webhooks.allowUnverifiedNotion` with no secret) must never fire a routine. See
920
+ // NotionDeliveryOutcome.verified.
921
+ //
922
+ // `waitUntil` because routing is not part of the response the vendor is waiting on, and
923
+ // `admitTrigger` NEVER throws for a bad delivery (every "can't route this" case is a report),
924
+ // so a structural event nobody subscribes to costs one normalize and stops.
925
+ //
926
+ // `headers: {}` is honest, not lazy — the Notion normalizer reads NOTHING from headers.
927
+ // Notion carries its event type, its parents AND its delivery id in the body, which is why
928
+ // `deliveryIdHeader` is absent on the normalizer and `notionEventDeliveryId` exists instead.
929
+ // 🔴 AND FENCED ON THE SIGNER (OPE-500). `verified` proves that SOME configured integration's
930
+ // secret signed these bytes. It does not prove that Notion composed them, so every scope field
931
+ // the normalizer reads is still the sender's choice. Per-domain subscription tokens are
932
+ // distributed by design (OPE-467), so the sender who chose them may be one domain's operator
933
+ // reaching for another domain's routines. notionTriggerScopeVerdict answers the one question
934
+ // that closes it: is the domain that owns the claimed scope a domain THIS signer serves?
935
+ //
936
+ // A refusal drops the delivery out of the trigger plane and says why in the log. It is not a
937
+ // 4xx — the bytes verified, and making Notion retry a refusal that will repeat is wrong.
938
+ let eventPlaneRefused = false;
939
+ if (outcome.verified) {
940
+ const fence = notionTriggerScopeVerdict(rawBody, outcome.signerCredentialId, notionDomains());
941
+ if (!fence.ok) {
942
+ eventPlaneRefused = true;
943
+ log(`event plane: delivery refused — ${fence.reason}`);
944
+ } else {
945
+ const deliveryId = notionEventDeliveryId(rawBody);
946
+ const eventPlane = admitTrigger(
947
+ {
948
+ source: NOTION_EVENT_SOURCE,
949
+ headers: {},
950
+ body: rawBody,
951
+ receivedAt: new Date().toISOString(),
952
+ ...(deliveryId ? { deliveryId } : {}),
953
+ },
954
+ { to },
955
+ ).then((report) => {
956
+ if (report.matched > 0) log(`event plane: ${report.matched} matched -> ${report.fired} fired, ${report.skipped} skipped`);
957
+ });
958
+ if (acceptedIngress) {
959
+ try {
960
+ await eventPlane;
961
+ } catch (err) {
962
+ await settleNotionIngressDrain(acceptedIngress, "retry", {
963
+ org: deploymentOrg(),
964
+ store: getOperationalStore(),
965
+ });
966
+ throw err;
967
+ }
968
+ } else {
969
+ waitUntil(eventPlane.catch((err) => log(`event plane: admitTrigger threw (routing lost for this delivery): ${String(err)}`)));
970
+ }
971
+ }
972
+ }
973
+
974
+ if (outcome.action === "handshake") {
975
+ // 🔴 THE TOKEN IS NEVER LOGGED (OPE-195). It is the PERMANENT HMAC key for every subsequent
976
+ // delivery on this subscription, so printing it — which this line used to do, and which
977
+ // .env.example and the Notion runbook used to instruct operators to rely on — put the
978
+ // signing key of a fail-closed ingress into log retention, forgeable by anyone with log
979
+ // access for as long as logs are kept. captureHandshakeSecret stores it in the encrypted
980
+ // vault under a RESERVED ref and logs a redaction + sha256 fingerprint + a single-use,
981
+ // 15-minute reveal link instead. That link is BEARER-ONLY (there is no clicker at handshake
982
+ // time) — see the module header. THE ONE CALL SITE: moving this ingress onto the
983
+ // handshake-capture plane deletes this branch and webhooks/handshake-capture.ts together.
984
+ try {
985
+ await captureHandshakeSecret(
986
+ { refNamespace: NOTION_HANDSHAKE_REF_NAMESPACE, label: "Notion webhook (verification_token)", value: handshakeToken(rawBody) },
987
+ { log },
988
+ );
989
+ } catch (err) {
990
+ // The old line could not throw; this one reaches the vault, so it can (no AES key, store
991
+ // down). Never let that 500 the handshake — Notion would mark the endpoint unverified and
992
+ // the operator would be debugging the wrong thing. Report the FAILURE, never the value:
993
+ // an uncaptured secret is a rotation (recreate the subscription), a logged one is an
994
+ // incident. `err` is a store/crypto error and carries no secret material.
995
+ log(`handshake secret could NOT be captured (${err instanceof Error ? err.message : String(err)}) — the value is NOT logged; delete and recreate the Notion subscription once fixed.`);
996
+ }
997
+ } else if (outcome.action === "dispatched") {
998
+ log(`comment.created → dispatched (${outcome.detail})`);
999
+ } else if (outcome.action === "stopped") {
1000
+ // OPE-989 — the audit row for the cancel itself (`turn.cancel_requested`) is already
1001
+ // written by `cancelAnchoredTurn`; this is only the operator-facing line.
1002
+ log(`stop comment → ${outcome.detail}`);
1003
+ } else if (outcome.action === "ambient-disabled") {
1004
+ // Was the generic `else` below: ONE log line, no row (OPE-205). `detail` is the surface
1005
+ // key by construction (notion-webhook.ts's gate). The gate itself is unchanged — this
1006
+ // only makes "the supervisor was never asked about this page" as answerable as the
1007
+ // notion.no_reply row we already write when it IS asked and judges silence.
1008
+ const surfaceKey = outcome.detail ?? "";
1009
+ waitUntil(
1010
+ auditAmbientSkip({
1011
+ platform: "notion",
1012
+ workspace: surfaceKey.split("/")[1] ?? "unknown",
1013
+ surfaceKey,
1014
+ log,
1015
+ }),
1016
+ );
1017
+ } else if (outcome.action === "scope-refused") {
1018
+ // OPE-502. A forged `workspace_id` is INVISIBLE on the surface — the agent simply never
1019
+ // wakes — so the refusal writes a row as well as a line, the same reason notion-scope.ts
1020
+ // gives for auditing a refused read. The reason string already names the page, the claimed
1021
+ // workspace, the owning domain and both credentials, each bounded and control-stripped
1022
+ // (notion-credentials.ts). Nothing of the fetched COMMENT is recorded.
1023
+ log(`delivery scope-refused: ${outcome.detail}`);
1024
+ waitUntil(
1025
+ appendAudit({
1026
+ org: deploymentOrg(),
1027
+ platform: "notion",
1028
+ // The claimed workspace is the field under suspicion, so it must not become an audit
1029
+ // dimension. It rides in `detail` inside the reason instead.
1030
+ workspace: "unknown",
1031
+ action: "notion.scope_refused",
1032
+ detail: { reason: outcome.detail ?? null, signer: outcome.signerCredentialId ?? null },
1033
+ }),
1034
+ );
1035
+ } else if (outcome.action === "rejected") {
1036
+ log(`delivery rejected (${outcome.status}): ${outcome.detail}`);
1037
+ waitUntil(
1038
+ appendAudit({
1039
+ org: deploymentOrg(),
1040
+ platform: "notion",
1041
+ workspace: "unknown",
1042
+ action: "notion.rejected",
1043
+ detail: { status: outcome.status, reason: outcome.detail },
1044
+ }),
1045
+ );
1046
+ } else {
1047
+ log(`delivery ${outcome.action}${outcome.detail ? ` (${outcome.detail})` : ""}`);
1048
+ }
1049
+
1050
+ // TELEMETRY ONLY. The delivery, dedupe mark and wake acceptance above remain synchronous;
1051
+ // this content-free row is the sole deferred addition and cannot lengthen the ACK it measures.
1052
+ // `unknown` dimensions are deliberate: the phase buckets need no workspace/page/comment,
1053
+ // credential or message data, and the action + audit timestamp are enough to find a sample.
1054
+ if (acceptedIngress) {
1055
+ await settleNotionIngressDrain(acceptedIngress, notionIngressDisposition(outcome, eventPlaneRefused), {
1056
+ org: deploymentOrg(),
1057
+ store: getOperationalStore(),
1058
+ });
1059
+ }
1060
+ const response = new Response(outcome.body, { status: outcome.status });
1061
+ finishTiming(outcome.action);
1062
+ return response;
1063
+ },
1064
+ );
1065
+
1066
+ export default defineChannel<NotionState, { state: NotionState }>({
1067
+ kindHint: "notion",
1068
+ // 🔴 QUEUE, NEVER STEER (OPE-412, OPE-1732). eve's default is `steer`, and a steer delivery
1069
+ // carrying a message CANCELS the turn in flight — rolling session history back to the last
1070
+ // completed model call while that call's tool side effects stay real and unrecorded. The policy
1071
+ // resolves per RECEIVING channel, and every Notion comment session lives on THIS channel, so no
1072
+ // other pin covers it. Notion ships no vendor factory, so `lint:turn-policy` cannot see this
1073
+ // line either — `scripts/notion-turn-policy-test.ts` is what holds it. See
1074
+ // packages/providers/slack/agent/channels/slack.ts for the full note.
1075
+ turnPolicy: "queue",
1076
+ state: { discussionId: null, pageId: null, workspaceId: null },
1077
+ context(state) {
1078
+ return { state };
1079
+ },
1080
+ routes: [
1081
+ POST(ROUTE, notionWebhookRouteHandler),
1082
+ POST(NOTION_INGRESS_DRAIN_PATH, notionWebhookRouteHandler),
1083
+ ],
1084
+ events: {
1085
+ // Snapshot the PER-TURN reply target from the delivery's auth attributes. The seeded
1086
+ // `state.discussionId` is applied by eve only when the session is CREATED — a resumed
1087
+ // standing supervisor session keeps its original state, so relying on the seed would post
1088
+ // every later reply into the FIRST discussion. But eve rewrites `session.auth.current`
1089
+ // from the DELIVERY's auth on every delivery, and buildNotionAuth stows discussion_id /
1090
+ // page_id / workspace_id there — so at turn.started they are THIS turn's real trigger
1091
+ // (the exact pattern slack.ts's supervisor turn.started snapshot uses, T1.4). A missing
1092
+ // discussion_id CLEARS the target (never carry a stale one forward) and message.completed
1093
+ // fails safe: no target ⇒ it posts NOTHING, never into a guessed thread.
1094
+ "turn.started"(_data, channel, ctx) {
1095
+ const attrs = ctx?.session?.auth?.current?.attributes as Record<string, unknown> | undefined;
1096
+ const s = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined);
1097
+ const discussionId = s(attrs?.discussion_id);
1098
+ channel.state.oeReplyTarget = discussionId
1099
+ ? ({
1100
+ discussionId,
1101
+ pageId: s(attrs?.page_id),
1102
+ workspaceId: s(attrs?.workspace_id),
1103
+ // OPE-467: the integration THIS delivery verified under. Snapshotted per turn like the
1104
+ // rest of the target, so a standing supervisor session answers each doc under the
1105
+ // integration that fetched that doc's comment.
1106
+ credentialId: s(attrs?.credential_id),
1107
+ } satisfies NotionReplyTarget)
1108
+ : undefined;
1109
+ // OPE-811: the OPE-508 REPLIER TIER snapshot is GONE from this handler, and nothing replaced
1110
+ // it. It existed because `message.completed` posted the reply and holds no live auth ctx. The
1111
+ // reply now leaves through the `reply` tool, whose own ctx resolves the grain and task label
1112
+ // directly (tools/reply.ts `replierFor`), and reply-post.ts bakes the bracket at the choke
1113
+ // point for every arm that cannot vary a display name — Notion included. One computation, at
1114
+ // the seam that posts.
1115
+ },
1116
+ // SHOW A PARKED QUESTION OR APPROVAL PROMPT (OPE-1275). Before this, eve's `input.requested` —
1117
+ // an `ask_question` mid-turn, or a `user-approval` turned `tool-approval` — reached this channel
1118
+ // and NOTHING answered it: the generic `defineChannel` this module builds on (unlike the
1119
+ // built-in `slackChannel`/`discordChannel`/`teamsChannel`/`githubChannel` every other native
1120
+ // provider wraps) ships no default handler at all, so a parked Notion turn showed nothing in the
1121
+ // discussion thread. The audit half (a request occurred) is a SEPARATE, surface-agnostic hook
1122
+ // (hooks/native-input-audit.ts) — this handler owns only the post.
1123
+ //
1124
+ // Posts through `postHostNotice` — `postReply` with NO audit parameter to pass, same posture as
1125
+ // the unified window's own bridge (channels/question-bridge.ts): a parked prompt is neither the
1126
+ // model's answer nor an answer to anything yet, so it must not write the `reply.post` row the
1127
+ // eval scorers join on.
1128
+ //
1129
+ // 🔴 AND IT REMEMBERS (OPE-1305), ONLY ON A POSTED QUESTION. The row keyed by this session's
1130
+ // token is what turns the discussion's next comment into `respond([{requestId, text}])` in the
1131
+ // `wake` dep above — the return leg a DELEGATED WORKER's question has no other way to reach. eve
1132
+ // proxies the child's request up through the PARENT's channel adapter, so the prompt reaches this
1133
+ // handler and lands in the thread, but the answer only reaches the child through an
1134
+ // `inputResponses` entry whose `requestId` sits in the parent's proxy map; a plain message falls
1135
+ // to `forSelf` and the parent eats it. Before this, OPE-1275 had traded an INVISIBLE unanswerable
1136
+ // question for a VISIBLE one — better, and a new false promise: the human answered in the thread
1137
+ // and believed they had answered. channels/notion-question-return.ts owns the leg and states the
1138
+ // keying rule; the only thing this handler must get right is that a DROPPED question writes
1139
+ // nothing, because nobody was shown anything that may be answered.
1140
+ //
1141
+ // NO WAKE ID RIDES THE ROW on this route, and none is missing: a wake id is minted on the
1142
+ // SINGLE-WINDOW path only (notion-supervisor.ts), and a windowed wake never reaches this handler
1143
+ // — it diverts to the unified channel, which runs its own leg. The field is an audit join, not a
1144
+ // routing input, so its absence costs a join and nothing else.
1145
+ //
1146
+ // 🔴 AWAITED, NOT FLOATED (cold review, 2026-08-29). Every other `input.requested` handler in
1147
+ // the tree awaits its post (channels/unified.ts, and linear.ts's delegation to eve's own
1148
+ // default) — an un-awaited post here could still be in flight when this invocation's own
1149
+ // response is sent and the host freezes it, turning the exact silence this handler exists to
1150
+ // remove into an intermittent one that is harder to diagnose, because the SEPARATE audit hook
1151
+ // (hooks/native-input-audit.ts) would still report the request as having happened.
1152
+ async "input.requested"(data, channel) {
1153
+ const target = channel.state.oeReplyTarget;
1154
+ if (target === undefined) return; // no resolvable discussion this turn — nothing to post into.
1155
+ const question = detectBridgeableInputRequest((data as { requests?: unknown } | undefined)?.requests);
1156
+ if (!question) return; // eve's session-limit park (or an unrecognisable batch) — not ours.
1157
+ try {
1158
+ await postHostNotice({ platform: "notion", ...target }, renderQuestionForSurface(question));
1159
+ } catch (err) {
1160
+ log(`input-request could not be posted into discussion ${target.discussionId}: ${String(err)}`);
1161
+ return; // NOT posted ⇒ NOT remembered: nothing was shown, so nothing may be consumed as its answer.
1162
+ }
1163
+ // POSTED ⇒ answerable. `rememberNotionQuestion` never throws; a `false` means this discussion
1164
+ // cannot answer after all (no usable token, or an unwritable store), which is the pre-OPE-1305
1165
+ // behaviour and is said out loud rather than left to look like success.
1166
+ const remembered = await rememberNotionQuestion({
1167
+ org: deploymentOrg(),
1168
+ namespacedToken: channel.continuation?.token,
1169
+ requestId: question.requestId,
1170
+ discussionId: target.discussionId,
1171
+ });
1172
+ log(
1173
+ remembered
1174
+ ? `question bridge: posted ${question.toolName} (${question.kind}) into discussion ${target.discussionId} — answerable there (${question.requestId})`
1175
+ : `question bridge: posted ${question.toolName} (${question.kind}) into discussion ${target.discussionId} — NOT answerable (no outstanding-question row); a delegated worker's question will stay parked`,
1176
+ );
1177
+ },
1178
+ // Deliver the supervisor's reply back into the SAME Notion discussion thread — OR audit its
1179
+ // judged SILENCE. finalMessageActionForTurn is the shared decision every surface calls (it
1180
+ // honours BOTH the NO_REPLY sentinel and the audited `no_reply` TOOL). A "silent" turn writes
1181
+ // a notion.no_reply audit row (parity with Slack — every silence is durable and answerable);
1182
+ // a "buffer-tool-status" turn (mid-turn tool call) is not terminal and is left alone.
1183
+ // The reply targets the PER-TURN snapshot (turn.started above), falling back to the seeded
1184
+ // state only for a session's FIRST turn shape where both agree anyway; audit rows fall back
1185
+ // to the seeded page/workspace when the snapshot omits them (context, not a post target).
1186
+ async "message.completed"(data, channel, ctx) {
1187
+ const sessionId = ctx?.session?.id; // stamped into the terminal audit rows → joins to the transcript
1188
+ const action = finalMessageActionForTurn(data.finishReason, data.message);
1189
+ if (action.kind === "buffer-tool-status") return; // not terminal — check BEFORE consuming below
1190
+ const target = channel.state.oeReplyTarget;
1191
+ const discussionId = target?.discussionId;
1192
+ // Read-and-reset the reply-tool signal on every TERMINAL completion (silent or post) — it must
1193
+ // never bleed into the next turn — mirrors slack.ts's consume-at-top. Feeds the shared arm's
1194
+ // stray-prose-leak accounting on the unified path. TURN-ID KEYED (OPE-805): only THIS turn's
1195
+ // own mark answers true — see reply-signal.ts's header.
1196
+ const repliedThisTurn = consumeSupervisorReplyMark(ctx);
1197
+ const org = deploymentOrg();
1198
+ const workspace = target?.workspaceId ?? channel.state.workspaceId ?? "unknown";
1199
+ // The SHARED ticketing completion arm (channels/supervisor-complete.ts). It POSTS NOTHING since
1200
+ // OPE-811: reply-post.ts's notion arm posts into the per-delivery discussion binding on ANY
1201
+ // Notion session, so the `reply` tool is the delivery on a per-doc supervisor turn exactly as
1202
+ // it already was on a windowed one, and the manifest's own midTurnPost is what the gate reads.
1203
+ //
1204
+ // 🔴 THE `oeUnified` CONDITION IS GONE, AND THAT IS THE POINT. It used to pass midTurnPost=false
1205
+ // on a NON-windowed per-doc session, so the gate failed open there and the turn's closing text
1206
+ // posted via createCommentReply. That was the surface's last prose delivery channel, and it made
1207
+ // one seat's delivery contract depend on its domain's topology — the divergence this ticket
1208
+ // removes. (OPE-787's warning still holds for anyone reading `oeUnified`: it is grain.unified,
1209
+ // true for EVERY windowed grain including a solo per-doc window, never "cross-surface".)
1210
+ // The notion.dispatched row above + replied/no_reply/turn.supervisor_prose_dropped = the
1211
+ // "a row closes a row" family — every terminal outcome closes the dispatched row.
1212
+ await completeSupervisorTurn({
1213
+ action,
1214
+ ...(sessionId ? { sessionId } : {}),
1215
+ repliedViaTool: repliedThisTurn,
1216
+ midTurnPost: capabilitiesForPlatform("notion").midTurnPost,
1217
+ noReplyAction: "notion.no_reply",
1218
+ repliedAction: "notion.replied",
1219
+ contextRow: { org, platform: "notion", workspace, surface: target?.pageId ?? channel.state.pageId ?? discussionId ?? "unknown" },
1220
+ repliedRow: { org, platform: "notion", workspace, surface: target?.pageId ?? channel.state.pageId ?? discussionId },
1221
+ silentAnchor: { discussionId: discussionId ?? channel.state.discussionId },
1222
+ droppedAnchor: { discussionId: discussionId ?? null },
1223
+ repliedAnchor: { discussionId },
1224
+ log: (m) => log(m),
1225
+ });
1226
+ },
1227
+ },
1228
+ });
1229
+
1230
+ // Re-parse the handshake token purely to hand it to captureHandshakeSecret above (handleNotion
1231
+ // Delivery already consumed it; this keeps that function's return type clean). Returns "" rather
1232
+ // than a placeholder string on a shape it can't read: capture must never store a sentinel as though
1233
+ // it were a key. Unreachable in practice — `action === "handshake"` implies parseVerification
1234
+ // Handshake already succeeded on this exact body — but a stored lie is worse than a skipped capture.
1235
+ function handshakeToken(rawBody: string): string {
1236
+ try {
1237
+ const t = (JSON.parse(rawBody) as { verification_token?: unknown }).verification_token;
1238
+ return typeof t === "string" ? t : "";
1239
+ } catch {
1240
+ return "";
1241
+ }
1242
+ }