@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,675 @@
1
+ // The eve-FREE orchestration core behind the Notion comment surface — the full
2
+ // webhook→verify→fetch→classify→dispatch decision, factored out of the channel wiring
3
+ // (notion.ts) so it's unit-testable with a mock fetch + a mock dispatch (no eve boot, no
4
+ // live Notion). Same split the GitHub path uses (routines/events.ts is the tested core;
5
+ // channels/github.ts is the thin route) and the generic webhook path (webhooks/gate.ts +
6
+ // webhooks/dispatch.ts under channels/webhooks.ts).
7
+ //
8
+ // AMBIENT-JUDGED SURFACE (notion-ambient-surface-design.md). The agent is ambiently present on
9
+ // any doc it's connected to: it reads EVERY comment.created and a standing per-doc SUPERVISOR
10
+ // session intelligently decides whether to engage — exactly like OE's Slack ambient supervisor,
11
+ // reusing renderWake's channel-watch judge WHOLESALE. This REPLACES the old mention-gate (Notion
12
+ // doesn't let a user @mention an internal integration, so that gate was structurally dead). The
13
+ // comment.created webhook is still a SIGNAL ONLY (carries the id, not the text) — so this
14
+ // orchestrator does the two-step (retrieve the comment), then classifies (self/bot/@mention/
15
+ // ambient) and dispatches to the supervisor. The whole ambient path is fail-closed OFF behind the
16
+ // per-domain ambientSupervisor flag (opt-in is DOMAIN-level).
17
+ //
18
+ // LOOP GUARD (hardened, design §4.2) — TWO HALVES, AND NEITHER ONE IS THE GUARD ON ITS OWN.
19
+ // • WHOSE WORDS (step 8e, classifyNotionComment): the agent reads its OWN posted replies, because
20
+ // each createCommentReply fires a fresh comment.created. OUR OWN reply is recognised by ID and
21
+ // by nothing else — `created_by.id` against EVERY configured integration's bot user id
22
+ // (OPE-1921, step 7 builds the set from `signingKeys`) — and the guard fails closed on every
23
+ // unreadable identity: the verifying integration's own id UNRESOLVED, an author with no
24
+ // `created_by.id`, and a SIBLING integration whose id did not resolve.
25
+ // 🔴 THE SET IS WHAT MAKES THIS A DEPLOYMENT GUARD. Two integrations can both see one page, so
26
+ // a reply A posted arrives on B's subscription. Against B's id alone it matched nothing, read
27
+ // as a person and woke a turn — and A's subscription then saw B's answer. Every reply is a new
28
+ // comment id, so the once-guard below cannot bound that loop.
29
+ // 🔴 A FOREIGN bot is still the weaker case: the only signal is `created_by.type`, which Notion
30
+ // omits on a comment (OPE-1857), so a bot belonging to SOMEONE ELSE reads here as a person.
31
+ // Read the classifier's own step list — it does not claim otherwise.
32
+ // • HOW MANY TIMES (step 12, firstNotionDispatchOfComment): one comment id wakes ONE turn inside
33
+ // the dedupe window. A redelivery of a PERSON's comment is invisible to the classifier — same
34
+ // author, same text — so without this half a vendor retry, or a page two subscriptions both
35
+ // deliver, buys a second turn and a second reply (ship #216: three dispatches, one comment).
36
+ //
37
+ // THE SURFACE COMES FROM THE FETCH, NOT THE PAYLOAD (OPE-496): the session's page — and therefore
38
+ // its domain, its read credential and the pages the read fence admits — is established from the
39
+ // comment Notion returned under the VERIFIED credential, never from `data.page_id`. A delivery whose
40
+ // page cannot be established that way is acked and dropped. Step 8 carries the reasoning.
41
+ //
42
+ // AND THE WORKSPACE IS FENCED, NOT PROVED (OPE-502): `ev.workspace_id` stays a payload field, because
43
+ // Notion returns no workspace id on any object and no fetch can confirm it. The surface key composes
44
+ // it with the page, so a forged workspace alone could move the session onto a domain that CLAIMS that
45
+ // workspace. Step 8b refuses a delivery whose page reaches such a domain unless the SIGNING
46
+ // integration serves it — the same move OPE-500 made on the trigger plane.
47
+ //
48
+ // AND THE PARTITION USES CONFIG'S WORD (OPE-536, completed by OPE-254): 8b's fence closes the
49
+ // domain boundary but leaves the SESSION PARTITION on the payload's workspace. Step 8c swaps in the
50
+ // workspace segment of the `reach` entry that MATCHED, so a stale or forged workspace beside a page
51
+ // config knows can no longer open a second partition beside the honest one. Read step 8c for what
52
+ // that does and does not cover on each leg — in particular, a workspace no domain reaches at all
53
+ // still composes its own key, which belongs to no domain and therefore opens nothing.
54
+ //
55
+ // AND THE PAGE'S TRUST IS WALKED, NOT ASSUMED (OPE-492): a page under an operator-declared TRUST
56
+ // ROOT binds `internal`. Step 10b resolves that ONCE per delivery, under the same verified
57
+ // credential, and stamps the matched root on the auth. It can only PROMOTE — no root, no stamp, and
58
+ // the binding keeps the fail-closed `external` it had before. A deployment that declares no root
59
+ // spends no API call on it.
60
+ //
61
+ // UNTRUSTED WRAP: the surrounding discussion comments are written by arbitrary workspace members,
62
+ // so they render INSIDE the wake envelope's untrusted wrap (renderWakeEnvelope), oldest-first, as
63
+ // backscroll siblings ending at the triggering comment — the SAME untrusted-wrapping every surface
64
+ // applies. The triggering comment is the trigger message; on a `direct` (@mention) it's the
65
+ // conversation driver, unwrapped, exactly like an addressed turn on every surface.
66
+ import { gateNotionDeliveryAcross, parseVerificationHandshake, notionEventType, commentIdFromEvent, pageIdFromEvent, retrieveComment, listBlockComments, resolveBlockOwningPage, richTextToPlainText, notionParentLink, NOTION_INGRESS_RETRY, } from "./notion-api.js";
67
+ import { buildNotionAuth, notionSurfaceKey } from "./notion-identity.js";
68
+ import { resolveNotionDeliveryTrust } from "./notion-ancestry-trust.js";
69
+ import { notionDeliveryScopeVerdict } from "./notion-credentials.js";
70
+ import { collectNotionOwnBotIds } from "./notion-bot-user.js";
71
+ import { notionPageDomain, notionReachWorkspace } from "./notion-scope.js";
72
+ import { classifyNotionComment } from "./notion-supervisor.js";
73
+ import { notionCommentIsStopDirective, routeNotionStopComment } from "./notion-stop.js";
74
+ import { firstNotionDispatchOfComment, resolveNotionEngagement } from "./notion-engagement.js";
75
+ import { OE_ADDRESSED_ATTR, OE_BOT_NAME_ATTR } from "@open-everywhere/core/agent/adapter-api.js";
76
+ /**
77
+ * The call options every Notion REST call {@link deliver} issues DIRECTLY takes (OPE-1254).
78
+ *
79
+ * ⚠️ Not quite every call of the delivery. `deps.resolveBotUserId` is awaited before any of these
80
+ * and reaches `retrieveBotUser` through its own path; `notion.ts` passes it the same policy at that
81
+ * call site. The two together are what make the ingress arithmetic hold.
82
+ *
83
+ * 🔴 ONE PLACE, BECAUSE THE POLICY IS THE POINT. These options were built inline at each of the four
84
+ * call sites below, so the 429 backoff each one carried was whatever the shared defaults are — a 60 s
85
+ * budget per call across about 15 sequential calls, ~15 minutes worst case, on a function the
86
+ * platform kills at 300 s. {@link NOTION_INGRESS_RETRY} is the tightened policy that fits under that
87
+ * ceiling, and routing every site through this helper is what stops the next call site from being
88
+ * added without it.
89
+ *
90
+ * 🔴 `deps.retry` OVERLAYS THE POLICY, IT DOES NOT REPLACE IT. A test injects a stub `sleep` so a
91
+ * retry sequence waits nothing, and it must keep the SHIPPED numbers while it does — otherwise the
92
+ * only test that can see this policy is one that passes the policy in, which proves nothing about
93
+ * the delivery path.
94
+ */
95
+ function ingressCallOpts(cred, deps) {
96
+ return { ...cred, fetch: deps.fetch, version: deps.version, retry: { ...NOTION_INGRESS_RETRY, ...deps.retry } };
97
+ }
98
+ /**
99
+ * Run the whole inbound loop for one Notion webhook delivery. Returns the HTTP outcome the
100
+ * route should send AND the semantic action (for the audit row). Pure w.r.t. eve — every
101
+ * external effect is injected (fetch, resolveBotUserId, resolveBotName, ambientEnabled, dispatch,
102
+ * onHandshakeToken).
103
+ */
104
+ export async function handleNotionDelivery(rawBody, signatureHeader, deps) {
105
+ // The HMAC verdict's own answer to "who signed this", collected the same way `signed` is: written
106
+ // once, at the gate, rather than threaded through the nine returns below. An out-parameter because
107
+ // `deliver` returns from ten places and only ONE of them knows this fact.
108
+ const signer = {};
109
+ const { signed, ...out } = await deliver(rawBody, signatureHeader, deps, signer);
110
+ // {@link NotionDeliveryOutcome.verified}, derived ONCE here rather than threaded through the nine
111
+ // returns below. `signed` is set at exactly one place — the moment an integration's secret
112
+ // verified the signature — so the three exclusions still hold by construction: the handshake
113
+ // returns before the gate, a rejection never gets past it, and the
114
+ // `webhooks.allowUnverifiedNotion` hatch gets past it with NO secret configured and therefore
115
+ // no signature checked (gateNotionDeliveryAcross reports verified:false for it).
116
+ const verified = signed === true && out.action !== "handshake" && out.action !== "rejected";
117
+ // Reported only when the delivery is VERIFIED, so the field cannot name an integration on an arm
118
+ // where nothing was proved. `signed` and this id are set together at the gate, so the guard is
119
+ // belt-and-braces rather than a second decision.
120
+ return { ...out, verified, ...(verified && signer.credentialId ? { signerCredentialId: signer.credentialId } : {}) };
121
+ }
122
+ /**
123
+ * Process a row that crossed {@link handleNotionDelivery}'s HMAC gate before it was committed to
124
+ * the durable ingress inbox. This is intentionally a separate entrypoint: the public route cannot
125
+ * pass an optional "already verified" flag, and the internal drain must supply the immutable signer
126
+ * identity from the row key. A deleted integration is not retargeted to another credential.
127
+ */
128
+ export async function handleAcceptedNotionDelivery(rawBody, signerCredentialId, deps) {
129
+ if (!deps.signingKeys.some((key) => key.credentialId === signerCredentialId)) {
130
+ return {
131
+ status: 200,
132
+ body: "ok",
133
+ action: "no-api-key",
134
+ detail: "the verified Notion integration is no longer configured",
135
+ verified: true,
136
+ signerCredentialId,
137
+ credentialId: signerCredentialId,
138
+ };
139
+ }
140
+ const signer = { credentialId: signerCredentialId };
141
+ const { signed: _signed, ...out } = await deliver(rawBody, null, deps, signer, signerCredentialId);
142
+ return { ...out, verified: true, signerCredentialId };
143
+ }
144
+ async function deliver(rawBody, signatureHeader, deps, signer, acceptedSignerCredentialId) {
145
+ // 1. One-time subscription handshake — log the token for the operator, ack 200. It arrives
146
+ // BEFORE any secret is configured, so it is (correctly) not signature-verified.
147
+ const handshake = acceptedSignerCredentialId === undefined ? parseVerificationHandshake(rawBody) : undefined;
148
+ if (handshake) {
149
+ deps.onHandshakeToken?.(handshake);
150
+ return { status: 200, body: "ok", action: "handshake" };
151
+ }
152
+ // 2. Fail-closed HMAC gate, across EVERY configured integration (OPE-467). The secret that
153
+ // verifies identifies the sender — nothing in the payload steers this.
154
+ const gate = acceptedSignerCredentialId !== undefined
155
+ ? { verified: true, credentialId: acceptedSignerCredentialId }
156
+ : deps.timing
157
+ ? deps.timing.measureSync("credential", () => gateNotionDeliveryAcross(deps.signingKeys, rawBody, signatureHeader, deps.allowUnverified))
158
+ : gateNotionDeliveryAcross(deps.signingKeys, rawBody, signatureHeader, deps.allowUnverified);
159
+ if ("rejection" in gate && gate.rejection) {
160
+ return { status: 401, body: gate.rejection, action: "rejected", detail: gate.rejection };
161
+ }
162
+ const signed = gate.verified;
163
+ // THE ONE WRITE (OPE-500). Everything past this line — including the `ignored` arm the event plane
164
+ // depends on — carries the signer out to the route.
165
+ if (gate.credentialId)
166
+ signer.credentialId = gate.credentialId;
167
+ // 3. Parse.
168
+ let ev;
169
+ try {
170
+ ev = JSON.parse(rawBody);
171
+ }
172
+ catch {
173
+ return { status: 400, body: "bad json", action: "bad-json", signed };
174
+ }
175
+ // 4. Only comment.created is a conversational trigger.
176
+ if (notionEventType(ev) !== "comment.created") {
177
+ return { status: 200, body: "ignored", action: "ignored", detail: notionEventType(ev), signed };
178
+ }
179
+ const commentId = commentIdFromEvent(ev);
180
+ if (!commentId)
181
+ return { status: 200, body: "ignored", action: "ignored", detail: "no comment id", signed };
182
+ // 5. WHICH INTEGRATION SERVES THIS DELIVERY (OPE-467).
183
+ //
184
+ // 🔴 THE VERIFYING INTEGRATION WINS. `gate.credentialId` is the one PROVEN fact here: whose
185
+ // secret verified THIS body. The page id below is read from the payload, which nothing has
186
+ // authenticated — so it must never move a delivery onto a DIFFERENT integration's credential.
187
+ // A forger holding integration A's `verification_token` can name a domain-B page; if the page
188
+ // binding outranked the HMAC, that forged body would run under B's key. It does not.
189
+ //
190
+ // PREFER, don't refuse, when the two disagree. A page legitimately shared with two
191
+ // integrations produces a delivery on EACH subscription, and each one has to run under its own
192
+ // verifying integration — refusing on disagreement would break that real case.
193
+ //
194
+ // `credentialForPage` is the fallback for the one arm where the gate names nothing: the
195
+ // `webhooks.allowUnverifiedNotion` dev hatch (accepted with NO secret configured, so no
196
+ // integration was identified). The org default is the last resort. Every REST call below —
197
+ // and the REPLY, which carries this id forward — runs under the one id this line picks.
198
+ const eventPageId = pageIdFromEvent(ev);
199
+ const credentialId = gate.credentialId ?? deps.credentialForPage(ev.workspace_id, eventPageId) ?? deps.signingKeys[0]?.credentialId;
200
+ if (!credentialId)
201
+ return { status: 200, body: "ok", action: "no-api-key", detail: "no Notion integration is configured", signed };
202
+ // 6. We need the API key to do the two-step fetch. Ack (don't make Notion retry a config bug).
203
+ const cred = deps.timing
204
+ ? await deps.timing.measure("credential", () => deps.credentialFor(credentialId))
205
+ : await deps.credentialFor(credentialId);
206
+ if (!cred) {
207
+ return { status: 200, body: "ok", action: "no-api-key", detail: `no value stored for notion credential "${credentialId}"`, signed, credentialId };
208
+ }
209
+ // 7. Resolve the bot's own identity for THIS integration (loop guard + name-drop input) and fetch
210
+ // the actual comment (the event is a signal → the text/mentions/author live on the comment).
211
+ const botUserId = deps.timing
212
+ ? await deps.timing.measure("botTriggerPage", () => deps.resolveBotUserId(credentialId))
213
+ : await deps.resolveBotUserId(credentialId);
214
+ // 🔴 AND THE SAME ANSWER FOR EVERY OTHER INTEGRATION WE RUN (OPE-1921). The loop guard has to
215
+ // recognise the deployment's own words, and this deployment posts under one bot user id PER
216
+ // INTEGRATION. {@link signingKeys} is already every configured integration, org default first, so
217
+ // the set is derived INSIDE this core from a required dep — a caller cannot forget to pass it and
218
+ // thereby reopen the loop, which is the reason `domains` is shaped the way it is (step 8b).
219
+ // COST: one `GET /v1/users/me` per SIBLING integration, memoised per credential for the process
220
+ // lifetime — so N-1 extra calls on this process's FIRST delivery and none after it. The verifying
221
+ // integration is filtered out because its id is already `botUserId`, which is what makes a
222
+ // single-integration deployment pay NOTHING (notion-test.ts's ack-attribution fixture measures it).
223
+ const siblingCredentialIds = deps.signingKeys.map((k) => k.credentialId).filter((id) => id !== credentialId);
224
+ // `canPost` is consulted ONLY for a sibling whose bot id did not resolve, so the ordinary path
225
+ // spends nothing on it. It separates "this integration can reach Notion and we cannot name it"
226
+ // (a real loop risk — fail closed) from "this integration has no usable key at all" (it cannot
227
+ // have authored anything, so failing closed on it would silence the deployment for nothing).
228
+ const collectOwn = () => collectNotionOwnBotIds(botUserId, siblingCredentialIds, deps.resolveBotUserId, async (id) => (await deps.credentialFor(id)) !== undefined);
229
+ const ownBotIds = deps.timing ? await deps.timing.measure("botTriggerPage", collectOwn) : await collectOwn();
230
+ const botName = deps.resolveBotName
231
+ ? deps.timing
232
+ ? await deps.timing.measure("botTriggerPage", () => deps.resolveBotName(credentialId))
233
+ : await deps.resolveBotName(credentialId)
234
+ : undefined;
235
+ let comment;
236
+ try {
237
+ comment = deps.timing
238
+ ? await deps.timing.measure("botTriggerPage", () => retrieveComment(commentId, ingressCallOpts(cred, deps)))
239
+ : await retrieveComment(commentId, ingressCallOpts(cred, deps));
240
+ }
241
+ catch (err) {
242
+ return { status: 200, body: "ok", action: "error", detail: `retrieveComment: ${String(err)}`, signed, credentialId };
243
+ }
244
+ // 8. WHICH PAGE THIS SESSION IS ON (OPE-496).
245
+ //
246
+ // 🔴 THE FETCHED COMMENT DECIDES, NOT THE PAYLOAD. The surface key built here is not a label:
247
+ // it selects the session's domain, and through the domain the pages notion-scope.ts admits for
248
+ // every READ TOOL (notion-scope.ts's roster/claim fence, never the credential — see OPE-499:
249
+ // the credential every read tool runs under is the DELIVERY's own carried one, resolved by
250
+ // notion-credentials.ts notionCallerCredential, and it does not depend on this surface key at
251
+ // all). `ev.data.page_id` is a payload field that nothing authenticates, so it must never pick
252
+ // that page — a holder of ANY ONE subscription's `verification_token` could name a page in a
253
+ // domain their integration was never given, and the session would surface on that domain.
254
+ //
255
+ // `comment.parent` came back from Notion under the credential step 5 proved (step 7's
256
+ // retrieveComment), so it is the authenticated answer to the same question. A page-level comment
257
+ // carries `page_id` directly; an INLINE comment carries `block_id` instead, so the block's
258
+ // owning page is resolved under the SAME credential (resolveBlockOwningPage — the walk the read
259
+ // fence already uses for the block drill-in).
260
+ //
261
+ // FAIL CLOSED when neither answers. The payload field is NOT a fallback here: falling back to it
262
+ // would restore the whole vector on exactly the deliveries whose shape is unusual, which is the
263
+ // shape an attacker picks. A delivery that resolves no page is ACKED (200 — Notion must not
264
+ // retry a resolution that will fail the same way) and dispatches nothing.
265
+ const resolvedPage = deps.timing
266
+ ? await deps.timing.measure("botTriggerPage", () => resolveDeliveryPage(comment, ingressCallOpts(cred, deps)))
267
+ : await resolveDeliveryPage(comment, ingressCallOpts(cred, deps));
268
+ if (!resolvedPage.pageId) {
269
+ return { status: 200, body: "ok", action: "unresolved-page", detail: resolvedPage.reason, signed, credentialId };
270
+ }
271
+ const pageId = resolvedPage.pageId;
272
+ const discussionId = comment.discussion_id ?? "";
273
+ const payloadWorkspaceId = ev.workspace_id;
274
+ // 8b. WHICH DOMAIN THIS SESSION MAY OPEN ON (OPE-502).
275
+ //
276
+ // 🔴 THE WORKSPACE IS STILL THE PAYLOAD'S WORD, HERE. Step 8 established the PAGE from the
277
+ // fetch, and it stops there — `ev.workspace_id` above is a payload field, and there is no
278
+ // verified source for a workspace id anywhere in Notion's REST surface. The two halves are then
279
+ // composed into `notion/<workspace>/<page>`, which picks the domain, and the domain picks the
280
+ // ambient flag, the read scope and (for an agent-initiated call) the credential. So a forged
281
+ // `workspace_id` beside an honest page moves the session onto whichever domain CLAIMS that
282
+ // workspace in a container entry — a claim reaches every page under it, including one nobody
283
+ // enumerated.
284
+ //
285
+ // THE SIGNER FENCES IT, exactly as OPE-500 fenced the trigger plane. The one authenticated fact
286
+ // a delivery carries is which integration's secret verified it, and `notion-credentials.ts`
287
+ // already knows which integration each domain binds. `notionDeliveryScopeVerdict` asks the one
288
+ // question that closes the gap: is every domain this page reaches THROUGH A CLAIM a domain THIS
289
+ // signer serves? A page reached through an EXACT `reach` entry is exempt and that is
290
+ // deliberate — read the function for why, and for what it does not close.
291
+ //
292
+ // `gate.credentialId` is set at exactly one place and only on a VERIFIED gate, so it is the
293
+ // signer and never the step-5 fallback (which may name an integration that proved nothing).
294
+ //
295
+ // ACKED, not 4xx: the bytes verified, and making Notion retry a refusal that will repeat is
296
+ // wrong. The refusal is its own action so the route can audit it.
297
+ //
298
+ // THE PAYLOAD VALUE, DELIBERATELY, EVEN THOUGH 8c BELOW REPLACES IT FOR THE PARTITION. This
299
+ // check is the domain-claim boundary, and the claim leg (`resolveDomainForChannel`'s roster leg
300
+ // needs the WHOLE key) is exactly what tells a rostered-but-cross-claimed forgery apart from an
301
+ // honest one — see notion-per-domain-credential-test.ts §9(d). Substituting the roster's own
302
+ // workspace before THIS call would make that case invisible to the fence.
303
+ const scope = notionDeliveryScopeVerdict(payloadWorkspaceId, pageId, gate.credentialId, deps.domains);
304
+ if (!scope.ok) {
305
+ return { status: 200, body: "ok", action: "scope-refused", detail: scope.reason, signed, credentialId };
306
+ }
307
+ // 8c. THE PARTITION'S WORKSPACE — CONFIG'S WORD, NOT THE PAYLOAD'S (OPE-536, completed by
308
+ // OPE-254 §2). 8b has now cleared the domain boundary; this step fixes a narrower, purely
309
+ // app-layer bug that clearing it does not touch.
310
+ //
311
+ // The surface key's workspace segment used to be whatever the payload said, and nothing
312
+ // authenticates that. A stale, mistyped or forged workspace beside an honest page composed a
313
+ // SECOND surface key (`notion/<wrong-workspace>/<page>`) beside the honest one, splitting one
314
+ // page's session + memory history across two partitions with no domain boundary ever crossed
315
+ // to explain it.
316
+ //
317
+ // `notionReachWorkspace` answers with the workspace segment of the `reach` entry that MATCHED
318
+ // — on both legs, exact and container. Notion returns no workspace id on any object, so the
319
+ // only place an operator can ever have read one is a real delivery for that page: the recorded
320
+ // segment IS the real workspace. The container leg is decidable because a domain may reach at
321
+ // most one Notion workspace BY CONTAINER (memory/domain.ts `partitionTenantProblems`, enforced
322
+ // at config load), so the container that swept a page in names exactly one workspace.
323
+ //
324
+ // 🔴 WHAT EACH LEG ACTUALLY BUYS, because the two are not the same size. On the EXACT leg the
325
+ // match is workspace-BLIND, so config and the payload can genuinely disagree and preferring
326
+ // config closes a real split (OPE-536). On the CONTAINER leg admission REQUIRES the payload's
327
+ // workspace to equal the container's own text, so the derived value equals the payload's by
328
+ // construction: what changes is not the value but where it comes from, plus the config rule
329
+ // above that makes "which workspace?" have one answer per domain (OPE-537 §1).
330
+ //
331
+ // Undefined means the page reaches NO domain — the one case config cannot answer, and the
332
+ // forged-workspace case on a container-reached page lands here rather than in the domain. The
333
+ // payload's value then composes a key that belongs to no domain: no memory, no bundle, and
334
+ // the ambient gate refuses it like any unrouted surface. Inventing the domain's workspace
335
+ // here instead would HAND a forged delivery that domain's brain — the opposite of the fix.
336
+ const workspaceId = notionReachWorkspace(payloadWorkspaceId, pageId, deps.domains) ?? payloadWorkspaceId;
337
+ const surfaceKey = notionSurfaceKey(workspaceId, pageId);
338
+ // 8d. THE STOP LEG (OPE-989) — the honest equivalent of the ✋ reaction on this surface.
339
+ //
340
+ // Notion pushes no reaction webhook (comment.created/updated/deleted is the whole comment
341
+ // event catalogue — see channels/notion-stop.ts's header for the vendor evidence), so the
342
+ // nearest honest equivalent is the GitHub answer: a comment whose ENTIRE body is the bare word
343
+ // "stop", read here — before the ambient gate and before any dispatch.
344
+ // `notionCommentIsStopDirective` owns the actor guard (fail-closed on an unresolved bot id, an
345
+ // UNIDENTIFIABLE author, a comment authored by ANY of our integrations, an unresolved sibling,
346
+ // or a declared bot author), so a "stop" from anyone but a resolved, distinct human never
347
+ // reaches `cancelAnchoredTurn`. It reads the SAME own-id set the loop guard does (OPE-1921), so
348
+ // a sibling integration's own "stop" can no longer cancel this integration's turn. Read that
349
+ // function for the case it still cannot separate: a FOREIGN bot, whose comment carries a real
350
+ // id and no `created_by.type`, reads there as a person — the same limit the loop guard has,
351
+ // stated in this file's header.
352
+ //
353
+ // 🔴 IT SUPPRESSES THE REST OF THE PIPELINE FOR ITS OWN DELIVERY, unconditionally on the
354
+ // ambient flag — a stop must not depend on the very feature it is asking to stand down.
355
+ // Falling through to the ambient gate/classify/dispatch below would wake the supervisor on the
356
+ // very comment that asked it to stop.
357
+ //
358
+ // RUNS BEFORE THE AMBIENT GATE ON PURPOSE (unlike steps 9-11), and this is safe: on a domain
359
+ // where ambient is disabled no Notion turn is ever started via a comment at all, so the cancel
360
+ // resolves `no_turn_found` — the same honest answer it would give after the gate, one step
361
+ // later.
362
+ if (notionCommentIsStopDirective(comment, ownBotIds)) {
363
+ const stop = () => routeNotionStopComment({ workspaceId, pageId, discussionId: discussionId || undefined, credentialId, requesterId: comment.created_by?.id }, { attachSession: deps.attachSession });
364
+ const result = deps.timing ? await deps.timing.measure("engagementDedupe", stop) : await stop();
365
+ return {
366
+ status: 200,
367
+ body: "ok",
368
+ action: "stopped",
369
+ detail: result.stopped ? result.outcome : result.reason,
370
+ signed,
371
+ credentialId,
372
+ };
373
+ }
374
+ // 8e. Classify (self / bot / unresolved-fail-closed / @mention / name-drop / ambient). The loop
375
+ // guard lives here (hardened): an unresolved botUserId DROPS.
376
+ //
377
+ // 🔴 THE COMPUTATION MOVED ABOVE THE AMBIENT GATE; THE DECISION DID NOT (OPE-1501). The gate
378
+ // below needs the ADDRESSING to answer, so the classifier — pure, no I/O, no store — runs
379
+ // first, and its `drop` verdict is still acted on where it always was, one step lower. A
380
+ // delivery whose page is neither addressed nor engaged therefore returns the identical
381
+ // `ambient-disabled` it returned before this seam existed.
382
+ const classification = classifyNotionComment(comment, ownBotIds, botName);
383
+ const seamFacts = {
384
+ workspaceId,
385
+ pageId,
386
+ ...(comment.created_by?.id ? { principalId: comment.created_by.id } : {}),
387
+ ...(credentialId ? { credentialId } : {}),
388
+ };
389
+ // The seam's READ half, and ONLY a plain (`channel-watch`) comment asks it. An ADDRESSED comment
390
+ // enrols the page instead (step 10c) and can never be a stood-down follow-up, because the address
391
+ // is what lifts a stop; a `drop` verdict — self-authored, bot-authored, or an unresolved bot id —
392
+ // is going nowhere, so paying a registry read for it would buy an answer no branch uses.
393
+ // `authorIsBot` is therefore false by construction here, and it is passed rather than assumed so
394
+ // the loop guard stays readable at the call site.
395
+ const seam = classification.addressing === "channel-watch"
396
+ ? deps.timing
397
+ ? await deps.timing.measure("engagementDedupe", () => resolveNotionEngagement(seamFacts, false, deps.engagement))
398
+ : await resolveNotionEngagement(seamFacts, false, deps.engagement)
399
+ : undefined;
400
+ // 9. Ambient gate — the WHOLE ambient path is fail-closed OFF unless this doc's domain opts in
401
+ // (DOMAIN-level opt-in, ambientSupervisorEnabled). An unopted doc does nothing (matching the
402
+ // old dead mention-gate's net effect, but now it's an explicit, audited decision).
403
+ //
404
+ // 🔴 IT NOW HAS TWO MORE DOORS, AND BOTH ARE THE §2.5 SEAM (OPE-1501 / OPE-1510).
405
+ // • ADDRESSED, IN A DECLARED DOMAIN. Notion is the only surface whose ADDRESSED leg sat
406
+ // behind the ambient opt-in: the gate returned before the classifier ran, so a page that
407
+ // had not opted into ambient could not be addressed at all — deaf, not merely
408
+ // un-followable, while every other surface answers a mention with no such opt-in. That
409
+ // asymmetry is what made the seam unreachable on an addressed-only page.
410
+ //
411
+ // 🔴 THE DOMAIN CONDITION IS NOT DECORATION. `notionDeliveryScopeVerdict` (step 8b) admits
412
+ // a page NO domain claims — "no claimed domain ⇒ allow" — because it fences claim-reached
413
+ // pages rather than deciding presence. Without this second clause a name-drop on ANY page
414
+ // the integration can see, in no domain at all, would run a turn: a far wider door than
415
+ // this seam asks for. `notionPageDomain` answers the narrow question — does an operator's
416
+ // own config, by roster or by claim, hold this page — and it is the same function step 8c
417
+ // already routes with. So the widening is exactly "a page the operator declared, which has
418
+ // not opted into ambient, can still be addressed".
419
+ // • ENGAGED. A page somebody addressed the agent on wakes on plain follow-ups, which is the
420
+ // whole §2.5 posture and the reason this ticket exists. It needs no domain clause: the
421
+ // engagement row cannot exist unless an addressed comment already passed the door above.
422
+ // Every other comment takes the gate exactly as it always did.
423
+ //
424
+ // 🔴 THE DOMAIN VERDICT IS RESOLVED ONCE, HERE, and travels to the route on the dispatch
425
+ // (`domainVia`). The gate below reads it, and the `notion.dispatched` row reports it — one
426
+ // read, so the row can never disagree with the door that admitted the delivery.
427
+ const pageDomain = notionPageDomain(workspaceId, pageId, deps.domains);
428
+ const addressedInDeclaredDomain = classification.addressing === "direct" && pageDomain.via !== "none";
429
+ if (!deps.ambientEnabled(surfaceKey) && !addressedInDeclaredDomain && seam?.kind !== "deliver") {
430
+ return { status: 200, body: "ok", action: "ambient-disabled", detail: surfaceKey, signed, credentialId };
431
+ }
432
+ // 10. Act on the classification (self / bot / unresolved-fail-closed).
433
+ if (classification.addressing === "drop") {
434
+ const reason = classification.reason;
435
+ const action = reason.startsWith("self") ? "skipped-self" : reason.startsWith("bot") ? "skipped-bot" : "dropped";
436
+ return { status: 200, body: "ok", action, detail: reason, signed, credentialId };
437
+ }
438
+ // Capture the post-drop narrowing before the later dispatch closure. TypeScript does not retain a
439
+ // property narrowing inside a closure, even though both the object and property are readonly.
440
+ const addressing = classification.addressing;
441
+ // 10c. THE §2.5 STOP (OPE-1501). A plain comment on a page `stand_down` marked is SUPPRESSED —
442
+ // including where the ambient supervisor is enabled and would otherwise answer it, which is the
443
+ // only thing that makes the verb a stop rather than a note. It is a read and a refusal, so it
444
+ // runs here, above the sibling fetch and the ancestor walk it makes pointless. An ADDRESSED
445
+ // comment never reaches this branch: the address is what lifts a stop.
446
+ //
447
+ // 🔴 THE MARK IS NOT HERE. It is a WRITE, and it belongs below the EXTERNAL-AUTHOR decision,
448
+ // which on this surface is made inside `handleNotionSupervisorDispatch` rather than at the
449
+ // ingress — see that function, and the `markEngaged` note on `NotionSupervisorDeps`. Marking
450
+ // here would let an author the operator excluded enrol a page (and clear a stand-down) by
451
+ // naming the agent on it.
452
+ if (classification.addressing !== "direct" && seam?.kind === "hold" && seam.stopped) {
453
+ return { status: 200, body: "ok", action: "stood-down", detail: seam.anchor, signed, credentialId };
454
+ }
455
+ // 10b. IS THIS PAGE UNDER A DECLARED TRUST ROOT (OPE-492)?
456
+ //
457
+ // The ancestor walk, run ONCE per ingested event — never per tool call, because the answer is
458
+ // about the PAGE and the page is fixed for the whole delivery. It runs HERE, after the
459
+ // classifier, so the agent's own reply loop (self/bot comments, already dropped above) never
460
+ // pays for it.
461
+ //
462
+ // 🔴 IT CAN ONLY PROMOTE. A matched root stamps `trust_root` on the auth and `bindNotion` binds
463
+ // `internal`; every other verdict stamps nothing, and the binding falls through to the
464
+ // per-surface vouch and then to the fail-closed `external` exactly as before. A deployment that
465
+ // declares no roots gets `no-roots` with ZERO API calls, so this step is free until it is used.
466
+ //
467
+ // 🔴 THE PAGE IS THE FETCHED ONE, NOT THE PAYLOAD'S. `pageId` came from step 8, and the walk
468
+ // runs under the same verified credential — so a forged `data.page_id` cannot name a page inside
469
+ // someone's trusted subtree and inherit its trust.
470
+ //
471
+ // THE WORKSPACE IS THE ROSTER'S WORD ON A ROSTERED PAGE, THE PAYLOAD'S OTHERWISE (step 8c) —
472
+ // and here it does not need to be more. It only SELECTS which root set to compare against
473
+ // (`prepareNotionTrustRoots` matches the workspace segment by exact string, so the spelling
474
+ // used HERE decides whether a declared root is even considered), and a root is matched by its
475
+ // globally-unique Notion page id. So a wrong workspace can point the walk at another
476
+ // workspace's roots — none of which can be an ancestor of this page — or at a workspace with
477
+ // no roots. Either way it can only lose a promotion, never manufacture one.
478
+ //
479
+ // The walk is bounded, cached and fail-closed on every miss (channels/notion-ancestry-trust.ts).
480
+ // `notionParentLink` never throws, so a Notion outage degrades to "not trusted".
481
+ const resolveTrust = () => resolveNotionDeliveryTrust(pageId, workspaceId ?? "", deps.trustRoots, {
482
+ parentOf: (id) => notionParentLink(id, ingressCallOpts(cred, deps)),
483
+ });
484
+ const trust = deps.timing ? await deps.timing.measure("ancestorSiblingTrust", resolveTrust) : await resolveTrust();
485
+ // 11. Build the wake parts + auth, then dispatch to the standing doc supervisor.
486
+ const triggerText = richTextToPlainText(comment.rich_text);
487
+ const trigger = {
488
+ from: "human", // self/bot already dropped in classify
489
+ // 🔴 THE API'S OWN SPELLING OF THE ID, NOT THE WEBHOOK'S (OPE-1847). This value rides to the seat
490
+ // catch-up as `SeatCatchUpRequest.excludeId`, and since the catch-up bound now admits the
491
+ // trigger's whole MINUTE, that id match is the ONLY thing that keeps the wake's own comment out
492
+ // of its own context. The other side of the match is `toNotionCommentSummary`, which reads
493
+ // `comment.id` from `GET /v1/comments`. `commentId` came from the webhook envelope's `entity.id`
494
+ // instead, so the two sides were resolved from different Notion surfaces and nothing proved they
495
+ // spell one uuid the same way. `comment` is the object `retrieveComment` returned above, so
496
+ // taking its id makes both sides the SAME field of the SAME API. The webhook id stays as the
497
+ // fallback: it is what addressed the retrieve, so it is never wrong, only possibly spelled
498
+ // differently.
499
+ id: comment.id ?? commentId,
500
+ authorId: comment.created_by?.id,
501
+ sentAt: comment.created_time ?? new Date().toISOString(),
502
+ text: triggerText.length > 0 ? triggerText : "(the comment had no additional text)",
503
+ // The comment object was fetched under the credential whose secret authenticated this delivery.
504
+ // Keep its block parent distinct from the discussion id: Notion lists comments by BLOCK, while
505
+ // replies and filtering use the DISCUSSION.
506
+ ...(comment.parent?.block_id ? { anchorId: comment.parent.block_id } : {}),
507
+ };
508
+ const loadSiblings = () => loadThreadSiblings(comment, pageId, discussionId, ownBotIds, ingressCallOpts(cred, deps));
509
+ const threadComments = deps.timing
510
+ ? await deps.timing.measure("ancestorSiblingTrust", loadSiblings)
511
+ : await loadSiblings();
512
+ const auth = buildNotionAuth({
513
+ workspaceId,
514
+ pageId,
515
+ discussionId: discussionId || undefined,
516
+ commentId,
517
+ principalId: comment.created_by?.id,
518
+ // The integration this delivery runs under (step 5) rides the auth so every reply for this turn
519
+ // posts under it. Host-set from the HMAC verdict — the comment body cannot reach it.
520
+ credentialId,
521
+ // The matched trust root (step 10b), when the walk found one. Host-set from Notion's own parent
522
+ // chain under the verified credential — the comment body cannot reach it either.
523
+ ...(trust.trusted && trust.matchedRoot ? { trustRoot: trust.matchedRoot } : {}),
524
+ });
525
+ // AUTHORITATIVE addressing stamp for the link-claim hook (identity/binding.ts currentTurnAddressed):
526
+ // a `direct` Notion wake is an ADDRESSED, single-author comment (a name-drop / structured @mention),
527
+ // so `link <code>` claiming may tolerate the leading name-drop token. Notion's ONLY way to address
528
+ // the agent is the name-drop, so without this stamp "oe link ABC…" would never be recognised as a
529
+ // claim (the whole-message anchor rejects the "oe " prefix). Stamped ONLY on `direct` — a judged
530
+ // `channel-watch` wake carries no stamp and stays strict (fail-closed), so the F1 envelope attack is
531
+ // unaffected. The resolved integration name rides along so a name-drop matching the deployment's
532
+ // ACTUAL bot name is stripped even without OE_AGENT_* set. Host-set (§AUTH-ATTRIBUTE PROVENANCE) —
533
+ // built here from the classifier verdict, never from the comment body — so it cannot be forged.
534
+ if (classification.addressing === "direct") {
535
+ auth.attributes[OE_ADDRESSED_ATTR] = "1";
536
+ if (botName && botName.trim().length >= 2)
537
+ auth.attributes[OE_BOT_NAME_ATTR] = botName.trim();
538
+ }
539
+ // Engagement calibration (the ambient-everywhere tier dial) — fail-soft to neutral.
540
+ let engagementTier;
541
+ try {
542
+ engagementTier = deps.engagementTier?.(surfaceKey);
543
+ }
544
+ catch {
545
+ engagementTier = undefined;
546
+ }
547
+ // 12. ONE WAKE PER COMMENT (OPE-1906) — the delivery half of the loop guard.
548
+ //
549
+ // 🔴 THE CLASSIFIER CANNOT SEE THIS AND THE AUDIT CANNOT EITHER. Step 8e answers "whose words
550
+ // are these"; a SECOND delivery of the same person's comment carries the same author and the
551
+ // same text, so it passes every gate above exactly as the first did and wakes a second turn —
552
+ // a second model spend, a second `notion.dispatched` row and a second reply in the discussion.
553
+ // On ship #216 one Notion discussion carrying ONE driver comment produced THREE dispatch rows.
554
+ //
555
+ // IT SITS HERE, AS LATE AS THIS FUNCTION ALLOWS, so every refusal the INGRESS makes — the drop,
556
+ // the ambient gate, the scope fence, the stand-down, the unresolved page — happens before a
557
+ // mark is spent, and a redelivery after the operator opens one of those gates still wakes a
558
+ // turn. Past this line the mark IS spent: the spend cap runs inside the dispatch closure
559
+ // (notion.ts `ingressSpendGate`), so an over-budget delivery marks and then declines — which
560
+ // costs nothing real, because that decline posts a VISIBLE refusal rather than going quiet.
561
+ // `firstNotionDispatchOfComment`
562
+ // states the rest: the mark precedes the wake so two concurrent deliveries cannot both run a
563
+ // turn, and it fails OPEN, so a store outage costs a duplicate turn rather than a deaf page.
564
+ const firstDispatch = () => deps.dispatchGuard
565
+ ? deps.dispatchGuard.claim(commentId)
566
+ : firstNotionDispatchOfComment(seamFacts, commentId, deps.engagement);
567
+ const first = deps.timing ? await deps.timing.measure("engagementDedupe", firstDispatch) : await firstDispatch();
568
+ if (!first) {
569
+ return { status: 200, body: "ok", action: "duplicate-delivery", detail: commentId, signed, credentialId };
570
+ }
571
+ const dispatch = () => deps.dispatch({
572
+ surfaceKey,
573
+ credentialId,
574
+ addressing,
575
+ pageId,
576
+ discussionId,
577
+ workspaceId: workspaceId ?? null,
578
+ auth,
579
+ trigger,
580
+ threadComments,
581
+ engagementTier,
582
+ // THE SEAM'S OWN VERDICT, carried so the route's `notion.dispatched` row can say what woke this
583
+ // delivery (OPE-1510). `deliver` is the ENGAGED door of the gate above, and it is the only door
584
+ // a plain comment has; an ADDRESSED comment never asks the seam, so it never carries the flag.
585
+ ...(seam?.kind === "deliver" ? { engagedFollowUp: true } : {}),
586
+ // HOW THE OPERATOR'S CONFIG HOLDS THIS PAGE (OPE-1510) — the same verdict the addressed door
587
+ // above gated on. `container` is a CONTAINER entry — the whole workspace, or a same-level
588
+ // page glob (memory/domain.ts) — so it admits a name-drop on a page nobody enumerated;
589
+ // `roster` is a page an operator listed by name. The audit row must tell the two apart,
590
+ // because they are different amounts of declared intent behind the same wake.
591
+ domainVia: pageDomain.via,
592
+ // TWO consumers (see NotionSupervisorDispatch.trust): the `notion.dispatched` audit row, and the
593
+ // single-window privacy floor (`notionSingleWindowPrivacyOk` reads `trust.trusted` as its
594
+ // admission leg, OPE-540) — trimming this field silently drops trust-root pages from the shared
595
+ // window. Omitted on the `no-roots` verdict, so a deployment that declares no trust root writes
596
+ // the same audit row it always did; the floor reads absence as "not internal".
597
+ ...(trust.reason === "no-roots" ? {} : { trust }),
598
+ });
599
+ if (deps.timing)
600
+ await deps.timing.measure("dispatch", dispatch);
601
+ else
602
+ await dispatch();
603
+ if (deps.dispatchGuard)
604
+ await deps.dispatchGuard.complete(commentId);
605
+ return { status: 200, body: "ok", action: "dispatched", detail: classification.addressing, signed, credentialId };
606
+ }
607
+ /**
608
+ * The page a delivery's comment REALLY lives on, established from the comment Notion returned under
609
+ * the verified credential (OPE-496). The webhook's step 8 explains why the payload's own `page_id`
610
+ * may not answer this; this function is the answer that replaces it.
611
+ *
612
+ * Two shapes, one credential:
613
+ * • a PAGE-level comment carries `parent.page_id` — already the answer, and already authenticated
614
+ * (the fetch that returned it ran under the integration whose secret verified the delivery);
615
+ * • an INLINE comment carries `parent.block_id` instead, so the block's owning page is walked under
616
+ * the SAME credential ({@link resolveBlockOwningPage}, bounded at BLOCK_PARENT_MAX_HOPS).
617
+ *
618
+ * Every other outcome is a refusal with a reason: no parent at all, a parent that is neither a page
619
+ * nor a block, or a block whose walk ends anywhere but a page (a database or workspace parent, an id
620
+ * the integration cannot see, or too many hops). The caller must treat a reason as "do nothing" —
621
+ * `resolveBlockOwningPage` already states the same rule for the read fence, and both callers keep it.
622
+ *
623
+ * Exported so the delivery test can drive each shape directly, with the mock fetch counting the
624
+ * block-walk calls.
625
+ */
626
+ export async function resolveDeliveryPage(comment, opts) {
627
+ const parent = comment.parent;
628
+ if (parent?.page_id)
629
+ return { pageId: parent.page_id };
630
+ if (parent?.block_id) {
631
+ const owner = await resolveBlockOwningPage(parent.block_id, opts);
632
+ return owner ? { pageId: owner } : { reason: `comment parent block "${parent.block_id}" resolved no owning page` };
633
+ }
634
+ return { reason: "the retrieved comment carries no page or block parent" };
635
+ }
636
+ /**
637
+ * Best-effort surrounding-conversation context: the OTHER comments in the same discussion thread
638
+ * on this page, as oldest-first {@link NotionUntieredWakeMessage} siblings for the wake envelope's
639
+ * backscroll (re-sent each wake — cheap at Notion volume — so the judge always has full thread
640
+ * context). Any failure yields [] (context is enrichment, never a precondition — same posture as
641
+ * the old renderContext path). The triggering comment is excluded (it's the trigger message).
642
+ * The bot's own prior comments are marked `self` so the judge tells its voice from a human's.
643
+ */
644
+ async function loadThreadSiblings(trigger, pageId, discussionId, own, opts) {
645
+ try {
646
+ const all = await listBlockComments(trigger.parent?.block_id ?? pageId, opts);
647
+ return all
648
+ .filter((c) => c.id !== trigger.id && (discussionId ? c.discussion_id === discussionId : true))
649
+ .map((c) => {
650
+ // OUR OWN VOICE IS THE DEPLOYMENT'S, NOT ONE INTEGRATION'S (OPE-1921). Compared against the
651
+ // verifying integration's id alone, a reply a SIBLING integration posted into this very
652
+ // discussion was rendered to the judge as another person's words.
653
+ const authorId = c.created_by?.id;
654
+ const isSelf = Boolean(authorId !== undefined && own.all.has(authorId));
655
+ // Only an IDENTIFIED PERSON author carries an id downstream (F3): a bot/integration
656
+ // or an author whose person-ness we can't confirm (created_by.type absent on the REST
657
+ // payload) is left id-less, so wake-trust resolves the sibling `relay` — never a human
658
+ // standing (peer/principal) on an unattributed or machine author. Fail-closed: no
659
+ // positive `type === "person"` ⇒ no id ⇒ relay. The comment still rides as context.
660
+ const isPerson = c.created_by?.type === "person";
661
+ return {
662
+ from: isSelf ? "self" : "human",
663
+ id: c.id ?? "",
664
+ authorId: isSelf || !isPerson ? undefined : authorId,
665
+ sentAt: c.created_time ?? "",
666
+ text: richTextToPlainText(c.rich_text),
667
+ };
668
+ })
669
+ .filter((m) => m.text.length > 0);
670
+ }
671
+ catch {
672
+ return [];
673
+ }
674
+ }
675
+ //# sourceMappingURL=notion-webhook.js.map