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