@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,963 @@
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
+
67
+ import {
68
+ gateNotionDeliveryAcross,
69
+ parseVerificationHandshake,
70
+ notionEventType,
71
+ commentIdFromEvent,
72
+ pageIdFromEvent,
73
+ retrieveComment,
74
+ listBlockComments,
75
+ resolveBlockOwningPage,
76
+ richTextToPlainText,
77
+ notionParentLink,
78
+ NOTION_INGRESS_RETRY,
79
+ type FetchLike,
80
+ type NotionWebhookEvent,
81
+ type NotionComment,
82
+ type NotionSigningKey,
83
+ type NotionCallOpts,
84
+ type ResolvedNotionCredential,
85
+ } from "./notion-api.js";
86
+ import { buildNotionAuth, notionSurfaceKey } from "./notion-identity.js";
87
+ import { resolveNotionDeliveryTrust, type NotionTrustRoots } from "./notion-ancestry-trust.js";
88
+ import { notionDeliveryScopeVerdict } from "./notion-credentials.js";
89
+ import { collectNotionOwnBotIds, type NotionOwnBotIds } from "./notion-bot-user.js";
90
+ import { notionPageDomain, notionReachWorkspace } from "./notion-scope.js";
91
+ import { classifyNotionComment, type NotionUntieredSupervisorDispatch, type NotionUntieredWakeMessage } from "./notion-supervisor.js";
92
+ import { notionCommentIsStopDirective, routeNotionStopComment } from "./notion-stop.js";
93
+ import { firstNotionDispatchOfComment, resolveNotionEngagement, type NotionEngagementDeps, type NotionSeamFacts } from "./notion-engagement.js";
94
+ import type { NotionWebhookTimingRecorder } from "./notion-webhook-timing.js";
95
+ import type { NotionCommentDispatchGuard } from "./notion-ingress-drain.js";
96
+ import { OE_ADDRESSED_ATTR, OE_BOT_NAME_ATTR, type AttachSessionForCancel, type Domain } from "@open-everywhere/core/agent/adapter-api.js";
97
+
98
+ /** The per-TURN reply target the channel snapshots at `turn.started` (see notion.ts). */
99
+ export interface NotionReplyTarget {
100
+ readonly discussionId: string;
101
+ readonly pageId?: string;
102
+ readonly workspaceId?: string;
103
+ /** The Notion integration THIS delivery verified under (OPE-467) — snapshotted with the rest of
104
+ * the target so the terminal reply posts under the credential that fetched the comment. Absent
105
+ * on a turn whose auth predates the stamp ⇒ the post falls back to the page's domain binding. */
106
+ readonly credentialId?: string;
107
+ }
108
+
109
+ /** The per-session state the channel stashes so `message.completed` can post the reply into
110
+ * the SAME discussion thread (see notion.ts's context factory). */
111
+ export interface NotionSessionState {
112
+ readonly discussionId: string | null;
113
+ readonly pageId: string | null;
114
+ readonly workspaceId: string | null;
115
+ /**
116
+ * The PER-TURN reply target, snapshotted at `turn.started` from the delivery's auth
117
+ * attributes — NOT the seeded fields above. eve applies a `send({ state })` seed only when
118
+ * it CREATES a session; a resumed (standing supervisor) session keeps its original state, so
119
+ * the seeded `discussionId` goes stale after the first wake and every later reply would land
120
+ * in the FIRST discussion. eve rewrites `session.auth.current` from the delivery's auth on
121
+ * every delivery, so the auth attributes ARE this turn's real trigger — the same snapshot
122
+ * pattern slack.ts's supervisor turn.started uses (T1.4). Mutable by design (assigned per
123
+ * turn); `undefined` = no resolvable target, and message.completed FAILS SAFE (posts nothing).
124
+ */
125
+ oeReplyTarget?: NotionReplyTarget;
126
+ /**
127
+ * True iff this session was CREATED as a UNIFIED (org, domain) supervisor session
128
+ * (notion-supervisor.ts seeds it; eve applies the seed at creation only, and a session's grain
129
+ * is fixed for its lifetime — the token shape can't change mid-generation). notion.ts's
130
+ * message.completed keys the message-tool-only silence gate on it — Notion has a WORKING
131
+ * `reply` tool on supervisor turns, so a unified turn's terminal prose is DROPPED (never
132
+ * posted) and a stray leak is audited. Absent/false on every per-doc session ⇒ the gate never
133
+ * fires ⇒ byte-identical flag-off behaviour.
134
+ */
135
+ readonly oeUnified?: boolean;
136
+ }
137
+
138
+ export interface HandleNotionDeliveryDeps {
139
+ /**
140
+ * EVERY configured Notion integration's inbound signing key, org default first
141
+ * (channels/notion-credentials.ts notionIntegrations). The gate tries each in turn and the one
142
+ * that verifies IDENTIFIES the sending integration — see gateNotionDeliveryAcross. A single-
143
+ * integration deployment passes a one-entry list and behaves exactly as it did before OPE-467.
144
+ */
145
+ readonly signingKeys: readonly NotionSigningKey[];
146
+ /** The `webhooks.allowUnverifiedNotion` setting — dev escape hatch only. */
147
+ readonly allowUnverified: boolean;
148
+ /** Resolve one Notion channel credential to its integration token AND that credential's endpoint
149
+ * rules (resolveNotionCredential). Called with the credential id the VERIFYING integration named
150
+ * (else {@link credentialForPage}), so a delivery's REST calls run under the integration that
151
+ * actually sent it. Undefined ⇒ unconfigured, and the delivery is acked without a dispatch (never
152
+ * a retry storm over a config bug).
153
+ *
154
+ * 🔴 The PAIR, never the bare key (OPE-505). Every REST call in this file spreads the result, so
155
+ * the credential's endpoint fence rides along; handing on only the token would return an unfenced
156
+ * fetch and silently disable the check on the whole webhook two-step. */
157
+ readonly credentialFor: (credentialId: string) => Promise<ResolvedNotionCredential | undefined>;
158
+ /** The credential the PAGE'S DOMAIN binds, or undefined when it binds none — see
159
+ * channels/notion-credentials.ts. It decides the credential for READS and REPLIES the agent
160
+ * initiates. On an INBOUND delivery it is only the fallback: the HMAC has already proven which
161
+ * integration sent this body, and a page id read off an unauthenticated payload never overrides
162
+ * a proof (step 5). */
163
+ readonly credentialForPage: (workspaceId: string | undefined, pageId: string | undefined) => string | undefined;
164
+ /**
165
+ * The domain registry, passed as DATA rather than as a resolved verdict (OPE-502).
166
+ *
167
+ * 🔴 THE FENCE ITSELF STAYS INSIDE THIS CORE ON PURPOSE. Step 8b's decision is a security
168
+ * boundary, so it must not be a closure a caller supplies — a caller that passed a permissive one,
169
+ * or forgot the field, would reopen the whole vector with nothing to notice it. `domains` is inert
170
+ * config, so the only thing a caller can get wrong is passing an EMPTY list, and an empty list
171
+ * fences nothing because it owns nothing. Required, never optional, for the same reason.
172
+ *
173
+ * The route hands it `getConfig().loadDomains()`, fail-soft to `[]` — the same value it already
174
+ * closes over for {@link credentialForPage} and {@link ambientEnabled}.
175
+ */
176
+ readonly domains: readonly Domain[];
177
+ /**
178
+ * The operator's declared Notion TRUST ROOTS, prepared (OPE-492) — the route passes
179
+ * `notionTrustRoots()`, the register core's `subtree: true` trust entries fill at boot (OPE-539).
180
+ *
181
+ * OPTIONAL, and that is safe where {@link HandleNotionDeliveryDeps.domains} is not: a missing
182
+ * roots list can only ever LOSE a promotion. Absent (or empty) ⇒ every page resolves `no-roots`,
183
+ * with no API call and no probe, and the delivery keeps the fail-closed `external` binding it had
184
+ * before this seam existed. `domains` is required for the opposite reason — a missing one would
185
+ * weaken a fence.
186
+ */
187
+ readonly trustRoots?: NotionTrustRoots;
188
+ /** Resolve the bot's own Notion user id FOR ONE INTEGRATION (cached per credential; env override
189
+ * or GET /v1/users/me). An unresolved id makes classifyNotionComment fail CLOSED (drop, never
190
+ * wake) — the loop guard. 🔴 It is per-integration on purpose: each Notion integration has its
191
+ * OWN bot user id, so one cached id shared across integrations would compare a reply against the
192
+ * wrong identity and break the loop guard in both directions.
193
+ * 🔴 THE CORE CALLS IT ONCE PER CONFIGURED INTEGRATION, not once per delivery (OPE-1921): step 7
194
+ * walks {@link signingKeys} to build the deployment's whole own-id set. There is deliberately no
195
+ * separate "all the ids" dep — the set is DERIVED here from a required one, so no caller can
196
+ * omit it and silently reopen the sibling loop, exactly as {@link domains} is required. */
197
+ readonly resolveBotUserId: (credentialId: string) => Promise<string | undefined>;
198
+ /** Resolve the bot's own display name for one integration (Step 4) — a judge input (the
199
+ * channel-watch preamble's "names you"), never a bypass. Optional so the core works before name
200
+ * capture is wired. */
201
+ readonly resolveBotName?: (credentialId: string) => Promise<string | undefined>;
202
+ /** Is the ambient supervisor enabled for this doc's surfaceKey? Fail-closed OFF (per-domain
203
+ * opt-in, ambientSupervisorEnabled). The WHOLE ambient path is gated behind this. */
204
+ readonly ambientEnabled: (surfaceKey: string) => boolean;
205
+ /** Resolve the doc's engagement calibration tier (memory/domain.ts engagementTierFor) for the
206
+ * judged preamble. Optional + fail-soft: absent/throwing ⇒ neutral (byte-identical). */
207
+ readonly engagementTier?: (surfaceKey: string) => "aggressive" | "neutral" | "reserved";
208
+ /** The §2.5 engagement seam's store seams (OPE-1501) — see channels/notion-engagement.ts.
209
+ * Optional: absent ⇒ the live operational store. Injected so a test drives an engaged or a
210
+ * stood-down page without one. */
211
+ readonly engagement?: NotionEngagementDeps;
212
+ /** Durable comment-level dispatch lease used only by the authenticated inbox processor. The
213
+ * synchronous development path keeps the legacy event_dedupe guard. */
214
+ readonly dispatchGuard?: NotionCommentDispatchGuard;
215
+ /** Injectable fetch (tests pass a mock; prod omits → global fetch). */
216
+ readonly fetch?: FetchLike;
217
+ readonly version?: string;
218
+ /** OVERLAY on the 429 policy every REST call of this delivery takes (OPE-1254). Live code passes
219
+ * none and gets {@link NOTION_INGRESS_RETRY} whole — the policy sized against the ingress
220
+ * function's 300 s ceiling. A test passes `{ sleep }` alone, which keeps the shipped numbers and
221
+ * only makes the waits instant. */
222
+ readonly retry?: NotionCallOpts["retry"];
223
+ /** Request-scoped, content-free latency attribution. Absent keeps non-route callers unchanged. */
224
+ readonly timing?: NotionWebhookTimingRecorder;
225
+ /** Route the classified comment to the standing doc supervisor (notion-supervisor.ts). The
226
+ * dispatch it receives is UNTIERED (OPE-596): this core holds no binding context, so the route
227
+ * leg resolves each author's tier before the supervisor core renders anything. */
228
+ readonly dispatch: (d: NotionUntieredSupervisorDispatch) => Promise<void>;
229
+ /** Called with the one-time verification token on the handshake POST (channel logs it). */
230
+ readonly onHandshakeToken?: (token: string) => void;
231
+ /** eve's cancel door, channel-agnostic (the route's own `args.attachSession`) — what the STOP LEG
232
+ * (OPE-989, step 8d below) calls through {@link routeNotionStopComment}. */
233
+ readonly attachSession: AttachSessionForCancel;
234
+ }
235
+
236
+ export interface NotionDeliveryOutcome {
237
+ readonly status: number;
238
+ readonly body: string;
239
+ readonly action:
240
+ | "handshake"
241
+ | "rejected"
242
+ | "bad-json"
243
+ | "ignored"
244
+ | "ambient-disabled"
245
+ | "skipped-self"
246
+ | "skipped-bot"
247
+ | "dropped"
248
+ | "dispatched"
249
+ | "no-api-key"
250
+ | "unresolved-page"
251
+ /** A bare `stop` comment from a genuine, distinct human (OPE-989) — the turn on this page was
252
+ * handed to `cancelAnchoredTurn`. `detail` carries the cancel outcome, never a refusal reason. */
253
+ | "stopped"
254
+ /** A plain comment on a page `stand_down` marked (OPE-1501) — suppressed by the §2.5 engagement
255
+ * registry, whatever the ambient opt-in says. `detail` carries the engagement anchor. Naming
256
+ * the agent again clears the flag, so this is never the outcome of an addressed comment. */
257
+ | "stood-down"
258
+ /** The signer does not serve the domain that owns this delivery's page (OPE-502, step 8b). */
259
+ | "scope-refused"
260
+ /** This comment id already woke a turn inside the dedupe window (OPE-1906, step 12) — a vendor
261
+ * retry, or a page two subscriptions both deliver. `detail` carries the comment id. Acked 200:
262
+ * the delivery was handled the first time, so Notion has nothing to retry. */
263
+ | "duplicate-delivery"
264
+ | "error";
265
+ readonly detail?: string;
266
+ /**
267
+ * 🔴 A REAL `x-notion-signature` WAS CHECKED AGAINST A CONFIGURED SECRET (OPE-189).
268
+ *
269
+ * NOT the same as "the gate let it through". `gateNotionDelivery` also returns "accepted" when no
270
+ * secret is set AND `webhooks.allowUnverifiedNotion` is on — a local-dev hatch whose only proof is
271
+ * possession of the URL. This flag is false in that case, and the route uses it to decide whether
272
+ * the delivery may enter the ROUTING plane at all.
273
+ *
274
+ * Why the two consumers differ, deliberately: the ambient supervisor reads a comment and judges
275
+ * whether to reply, which is what the hatch exists to let you exercise on a laptop. `admitTrigger`
276
+ * fires member-authored, human-approved routines — so an unsigned acceptance must not reach it, or
277
+ * a dev hatch becomes a production capability the moment the env var is set on a real deployment.
278
+ * Same narrowing S0b applied to runtime hooks, for the same reason.
279
+ *
280
+ * False for the handshake (it arrives BEFORE any secret exists and is correctly unverified) and
281
+ * for every rejection.
282
+ */
283
+ readonly verified: boolean;
284
+ /**
285
+ * The Notion channel credential this delivery ran under (OPE-467) — the integration whose secret
286
+ * verified the signature, else (dev hatch only) the page's domain binding. Absent when the
287
+ * delivery never reached credential selection (handshake, rejection, bad json, ignored event).
288
+ * Reported so the route can audit WHICH integration served a page without a second resolution.
289
+ */
290
+ readonly credentialId?: string;
291
+ /**
292
+ * 🔴 THE INTEGRATION WHOSE SECRET VERIFIED THIS BODY (OPE-500) — the ONE authenticated identity a
293
+ * delivery carries, and a strict subset of {@link NotionDeliveryOutcome.credentialId}.
294
+ *
295
+ * NOT the same field. `credentialId` is "which integration SERVED this delivery", which falls back
296
+ * to the page binding and then to the first configured key on the dev-hatch arm — so it can name an
297
+ * integration that proved nothing. This one is set at exactly one place, from the HMAC verdict, and
298
+ * is absent whenever no secret verified (handshake, rejection, the unverified dev hatch).
299
+ *
300
+ * Present on EVERY arm past the gate, `ignored` included. The event plane needs it precisely on the
301
+ * arm the comment path drops: a `page.*` delivery never reaches credential selection, and
302
+ * `notionTriggerScopeVerdict` cannot fence a scope without knowing who signed for it.
303
+ */
304
+ readonly signerCredentialId?: string;
305
+ }
306
+
307
+ /**
308
+ * The call options every Notion REST call {@link deliver} issues DIRECTLY takes (OPE-1254).
309
+ *
310
+ * ⚠️ Not quite every call of the delivery. `deps.resolveBotUserId` is awaited before any of these
311
+ * and reaches `retrieveBotUser` through its own path; `notion.ts` passes it the same policy at that
312
+ * call site. The two together are what make the ingress arithmetic hold.
313
+ *
314
+ * 🔴 ONE PLACE, BECAUSE THE POLICY IS THE POINT. These options were built inline at each of the four
315
+ * call sites below, so the 429 backoff each one carried was whatever the shared defaults are — a 60 s
316
+ * budget per call across about 15 sequential calls, ~15 minutes worst case, on a function the
317
+ * platform kills at 300 s. {@link NOTION_INGRESS_RETRY} is the tightened policy that fits under that
318
+ * ceiling, and routing every site through this helper is what stops the next call site from being
319
+ * added without it.
320
+ *
321
+ * 🔴 `deps.retry` OVERLAYS THE POLICY, IT DOES NOT REPLACE IT. A test injects a stub `sleep` so a
322
+ * retry sequence waits nothing, and it must keep the SHIPPED numbers while it does — otherwise the
323
+ * only test that can see this policy is one that passes the policy in, which proves nothing about
324
+ * the delivery path.
325
+ */
326
+ function ingressCallOpts(cred: ResolvedNotionCredential, deps: HandleNotionDeliveryDeps): NotionCallOpts {
327
+ return { ...cred, fetch: deps.fetch, version: deps.version, retry: { ...NOTION_INGRESS_RETRY, ...deps.retry } };
328
+ }
329
+
330
+ /**
331
+ * Run the whole inbound loop for one Notion webhook delivery. Returns the HTTP outcome the
332
+ * route should send AND the semantic action (for the audit row). Pure w.r.t. eve — every
333
+ * external effect is injected (fetch, resolveBotUserId, resolveBotName, ambientEnabled, dispatch,
334
+ * onHandshakeToken).
335
+ */
336
+ export async function handleNotionDelivery(
337
+ rawBody: string,
338
+ signatureHeader: string | null,
339
+ deps: HandleNotionDeliveryDeps,
340
+ ): Promise<NotionDeliveryOutcome> {
341
+ // The HMAC verdict's own answer to "who signed this", collected the same way `signed` is: written
342
+ // once, at the gate, rather than threaded through the nine returns below. An out-parameter because
343
+ // `deliver` returns from ten places and only ONE of them knows this fact.
344
+ const signer: { credentialId?: string } = {};
345
+ const { signed, ...out } = await deliver(rawBody, signatureHeader, deps, signer);
346
+ // {@link NotionDeliveryOutcome.verified}, derived ONCE here rather than threaded through the nine
347
+ // returns below. `signed` is set at exactly one place — the moment an integration's secret
348
+ // verified the signature — so the three exclusions still hold by construction: the handshake
349
+ // returns before the gate, a rejection never gets past it, and the
350
+ // `webhooks.allowUnverifiedNotion` hatch gets past it with NO secret configured and therefore
351
+ // no signature checked (gateNotionDeliveryAcross reports verified:false for it).
352
+ const verified = signed === true && out.action !== "handshake" && out.action !== "rejected";
353
+ // Reported only when the delivery is VERIFIED, so the field cannot name an integration on an arm
354
+ // where nothing was proved. `signed` and this id are set together at the gate, so the guard is
355
+ // belt-and-braces rather than a second decision.
356
+ return { ...out, verified, ...(verified && signer.credentialId ? { signerCredentialId: signer.credentialId } : {}) };
357
+ }
358
+
359
+ /**
360
+ * Process a row that crossed {@link handleNotionDelivery}'s HMAC gate before it was committed to
361
+ * the durable ingress inbox. This is intentionally a separate entrypoint: the public route cannot
362
+ * pass an optional "already verified" flag, and the internal drain must supply the immutable signer
363
+ * identity from the row key. A deleted integration is not retargeted to another credential.
364
+ */
365
+ export async function handleAcceptedNotionDelivery(
366
+ rawBody: string,
367
+ signerCredentialId: string,
368
+ deps: HandleNotionDeliveryDeps,
369
+ ): Promise<NotionDeliveryOutcome> {
370
+ if (!deps.signingKeys.some((key) => key.credentialId === signerCredentialId)) {
371
+ return {
372
+ status: 200,
373
+ body: "ok",
374
+ action: "no-api-key",
375
+ detail: "the verified Notion integration is no longer configured",
376
+ verified: true,
377
+ signerCredentialId,
378
+ credentialId: signerCredentialId,
379
+ };
380
+ }
381
+ const signer = { credentialId: signerCredentialId };
382
+ const { signed: _signed, ...out } = await deliver(rawBody, null, deps, signer, signerCredentialId);
383
+ return { ...out, verified: true, signerCredentialId };
384
+ }
385
+
386
+ /** {@link deliver}'s private return: the outcome plus the ONE bit the wrapper turns into
387
+ * {@link NotionDeliveryOutcome.verified} — did an integration's secret actually verify this body? */
388
+ type DeliverOutcome = Omit<NotionDeliveryOutcome, "verified"> & { readonly signed?: boolean };
389
+
390
+ async function deliver(
391
+ rawBody: string,
392
+ signatureHeader: string | null,
393
+ deps: HandleNotionDeliveryDeps,
394
+ signer: { credentialId?: string },
395
+ acceptedSignerCredentialId?: string,
396
+ ): Promise<DeliverOutcome> {
397
+ // 1. One-time subscription handshake — log the token for the operator, ack 200. It arrives
398
+ // BEFORE any secret is configured, so it is (correctly) not signature-verified.
399
+ const handshake = acceptedSignerCredentialId === undefined ? parseVerificationHandshake(rawBody) : undefined;
400
+ if (handshake) {
401
+ deps.onHandshakeToken?.(handshake);
402
+ return { status: 200, body: "ok", action: "handshake" };
403
+ }
404
+
405
+ // 2. Fail-closed HMAC gate, across EVERY configured integration (OPE-467). The secret that
406
+ // verifies identifies the sender — nothing in the payload steers this.
407
+ const gate = acceptedSignerCredentialId !== undefined
408
+ ? { verified: true as const, credentialId: acceptedSignerCredentialId }
409
+ : deps.timing
410
+ ? deps.timing.measureSync("credential", () => gateNotionDeliveryAcross(deps.signingKeys, rawBody, signatureHeader, deps.allowUnverified))
411
+ : gateNotionDeliveryAcross(deps.signingKeys, rawBody, signatureHeader, deps.allowUnverified);
412
+ if ("rejection" in gate && gate.rejection) {
413
+ return { status: 401, body: gate.rejection, action: "rejected", detail: gate.rejection };
414
+ }
415
+ const signed = gate.verified;
416
+ // THE ONE WRITE (OPE-500). Everything past this line — including the `ignored` arm the event plane
417
+ // depends on — carries the signer out to the route.
418
+ if (gate.credentialId) signer.credentialId = gate.credentialId;
419
+
420
+ // 3. Parse.
421
+ let ev: NotionWebhookEvent;
422
+ try {
423
+ ev = JSON.parse(rawBody) as NotionWebhookEvent;
424
+ } catch {
425
+ return { status: 400, body: "bad json", action: "bad-json", signed };
426
+ }
427
+
428
+ // 4. Only comment.created is a conversational trigger.
429
+ if (notionEventType(ev) !== "comment.created") {
430
+ return { status: 200, body: "ignored", action: "ignored", detail: notionEventType(ev), signed };
431
+ }
432
+ const commentId = commentIdFromEvent(ev);
433
+ if (!commentId) return { status: 200, body: "ignored", action: "ignored", detail: "no comment id", signed };
434
+
435
+ // 5. WHICH INTEGRATION SERVES THIS DELIVERY (OPE-467).
436
+ //
437
+ // 🔴 THE VERIFYING INTEGRATION WINS. `gate.credentialId` is the one PROVEN fact here: whose
438
+ // secret verified THIS body. The page id below is read from the payload, which nothing has
439
+ // authenticated — so it must never move a delivery onto a DIFFERENT integration's credential.
440
+ // A forger holding integration A's `verification_token` can name a domain-B page; if the page
441
+ // binding outranked the HMAC, that forged body would run under B's key. It does not.
442
+ //
443
+ // PREFER, don't refuse, when the two disagree. A page legitimately shared with two
444
+ // integrations produces a delivery on EACH subscription, and each one has to run under its own
445
+ // verifying integration — refusing on disagreement would break that real case.
446
+ //
447
+ // `credentialForPage` is the fallback for the one arm where the gate names nothing: the
448
+ // `webhooks.allowUnverifiedNotion` dev hatch (accepted with NO secret configured, so no
449
+ // integration was identified). The org default is the last resort. Every REST call below —
450
+ // and the REPLY, which carries this id forward — runs under the one id this line picks.
451
+ const eventPageId = pageIdFromEvent(ev);
452
+ const credentialId = gate.credentialId ?? deps.credentialForPage(ev.workspace_id, eventPageId) ?? deps.signingKeys[0]?.credentialId;
453
+ if (!credentialId) return { status: 200, body: "ok", action: "no-api-key", detail: "no Notion integration is configured", signed };
454
+
455
+ // 6. We need the API key to do the two-step fetch. Ack (don't make Notion retry a config bug).
456
+ const cred = deps.timing
457
+ ? await deps.timing.measure("credential", () => deps.credentialFor(credentialId))
458
+ : await deps.credentialFor(credentialId);
459
+ if (!cred) {
460
+ return { status: 200, body: "ok", action: "no-api-key", detail: `no value stored for notion credential "${credentialId}"`, signed, credentialId };
461
+ }
462
+
463
+ // 7. Resolve the bot's own identity for THIS integration (loop guard + name-drop input) and fetch
464
+ // the actual comment (the event is a signal → the text/mentions/author live on the comment).
465
+ const botUserId = deps.timing
466
+ ? await deps.timing.measure("botTriggerPage", () => deps.resolveBotUserId(credentialId))
467
+ : await deps.resolveBotUserId(credentialId);
468
+ // 🔴 AND THE SAME ANSWER FOR EVERY OTHER INTEGRATION WE RUN (OPE-1921). The loop guard has to
469
+ // recognise the deployment's own words, and this deployment posts under one bot user id PER
470
+ // INTEGRATION. {@link signingKeys} is already every configured integration, org default first, so
471
+ // the set is derived INSIDE this core from a required dep — a caller cannot forget to pass it and
472
+ // thereby reopen the loop, which is the reason `domains` is shaped the way it is (step 8b).
473
+ // COST: one `GET /v1/users/me` per SIBLING integration, memoised per credential for the process
474
+ // lifetime — so N-1 extra calls on this process's FIRST delivery and none after it. The verifying
475
+ // integration is filtered out because its id is already `botUserId`, which is what makes a
476
+ // single-integration deployment pay NOTHING (notion-test.ts's ack-attribution fixture measures it).
477
+ const siblingCredentialIds = deps.signingKeys.map((k) => k.credentialId).filter((id) => id !== credentialId);
478
+ // `canPost` is consulted ONLY for a sibling whose bot id did not resolve, so the ordinary path
479
+ // spends nothing on it. It separates "this integration can reach Notion and we cannot name it"
480
+ // (a real loop risk — fail closed) from "this integration has no usable key at all" (it cannot
481
+ // have authored anything, so failing closed on it would silence the deployment for nothing).
482
+ const collectOwn = () => collectNotionOwnBotIds(
483
+ botUserId,
484
+ siblingCredentialIds,
485
+ deps.resolveBotUserId,
486
+ async (id) => (await deps.credentialFor(id)) !== undefined,
487
+ );
488
+ const ownBotIds = deps.timing ? await deps.timing.measure("botTriggerPage", collectOwn) : await collectOwn();
489
+ const botName = deps.resolveBotName
490
+ ? deps.timing
491
+ ? await deps.timing.measure("botTriggerPage", () => deps.resolveBotName!(credentialId))
492
+ : await deps.resolveBotName(credentialId)
493
+ : undefined;
494
+
495
+ let comment: NotionComment;
496
+ try {
497
+ comment = deps.timing
498
+ ? await deps.timing.measure("botTriggerPage", () => retrieveComment(commentId, ingressCallOpts(cred, deps)))
499
+ : await retrieveComment(commentId, ingressCallOpts(cred, deps));
500
+ } catch (err) {
501
+ return { status: 200, body: "ok", action: "error", detail: `retrieveComment: ${String(err)}`, signed, credentialId };
502
+ }
503
+
504
+ // 8. WHICH PAGE THIS SESSION IS ON (OPE-496).
505
+ //
506
+ // 🔴 THE FETCHED COMMENT DECIDES, NOT THE PAYLOAD. The surface key built here is not a label:
507
+ // it selects the session's domain, and through the domain the pages notion-scope.ts admits for
508
+ // every READ TOOL (notion-scope.ts's roster/claim fence, never the credential — see OPE-499:
509
+ // the credential every read tool runs under is the DELIVERY's own carried one, resolved by
510
+ // notion-credentials.ts notionCallerCredential, and it does not depend on this surface key at
511
+ // all). `ev.data.page_id` is a payload field that nothing authenticates, so it must never pick
512
+ // that page — a holder of ANY ONE subscription's `verification_token` could name a page in a
513
+ // domain their integration was never given, and the session would surface on that domain.
514
+ //
515
+ // `comment.parent` came back from Notion under the credential step 5 proved (step 7's
516
+ // retrieveComment), so it is the authenticated answer to the same question. A page-level comment
517
+ // carries `page_id` directly; an INLINE comment carries `block_id` instead, so the block's
518
+ // owning page is resolved under the SAME credential (resolveBlockOwningPage — the walk the read
519
+ // fence already uses for the block drill-in).
520
+ //
521
+ // FAIL CLOSED when neither answers. The payload field is NOT a fallback here: falling back to it
522
+ // would restore the whole vector on exactly the deliveries whose shape is unusual, which is the
523
+ // shape an attacker picks. A delivery that resolves no page is ACKED (200 — Notion must not
524
+ // retry a resolution that will fail the same way) and dispatches nothing.
525
+ const resolvedPage = deps.timing
526
+ ? await deps.timing.measure("botTriggerPage", () => resolveDeliveryPage(comment, ingressCallOpts(cred, deps)))
527
+ : await resolveDeliveryPage(comment, ingressCallOpts(cred, deps));
528
+ if (!resolvedPage.pageId) {
529
+ return { status: 200, body: "ok", action: "unresolved-page", detail: resolvedPage.reason, signed, credentialId };
530
+ }
531
+ const pageId = resolvedPage.pageId;
532
+ const discussionId = comment.discussion_id ?? "";
533
+ const payloadWorkspaceId = ev.workspace_id;
534
+
535
+ // 8b. WHICH DOMAIN THIS SESSION MAY OPEN ON (OPE-502).
536
+ //
537
+ // 🔴 THE WORKSPACE IS STILL THE PAYLOAD'S WORD, HERE. Step 8 established the PAGE from the
538
+ // fetch, and it stops there — `ev.workspace_id` above is a payload field, and there is no
539
+ // verified source for a workspace id anywhere in Notion's REST surface. The two halves are then
540
+ // composed into `notion/<workspace>/<page>`, which picks the domain, and the domain picks the
541
+ // ambient flag, the read scope and (for an agent-initiated call) the credential. So a forged
542
+ // `workspace_id` beside an honest page moves the session onto whichever domain CLAIMS that
543
+ // workspace in a container entry — a claim reaches every page under it, including one nobody
544
+ // enumerated.
545
+ //
546
+ // THE SIGNER FENCES IT, exactly as OPE-500 fenced the trigger plane. The one authenticated fact
547
+ // a delivery carries is which integration's secret verified it, and `notion-credentials.ts`
548
+ // already knows which integration each domain binds. `notionDeliveryScopeVerdict` asks the one
549
+ // question that closes the gap: is every domain this page reaches THROUGH A CLAIM a domain THIS
550
+ // signer serves? A page reached through an EXACT `reach` entry is exempt and that is
551
+ // deliberate — read the function for why, and for what it does not close.
552
+ //
553
+ // `gate.credentialId` is set at exactly one place and only on a VERIFIED gate, so it is the
554
+ // signer and never the step-5 fallback (which may name an integration that proved nothing).
555
+ //
556
+ // ACKED, not 4xx: the bytes verified, and making Notion retry a refusal that will repeat is
557
+ // wrong. The refusal is its own action so the route can audit it.
558
+ //
559
+ // THE PAYLOAD VALUE, DELIBERATELY, EVEN THOUGH 8c BELOW REPLACES IT FOR THE PARTITION. This
560
+ // check is the domain-claim boundary, and the claim leg (`resolveDomainForChannel`'s roster leg
561
+ // needs the WHOLE key) is exactly what tells a rostered-but-cross-claimed forgery apart from an
562
+ // honest one — see notion-per-domain-credential-test.ts §9(d). Substituting the roster's own
563
+ // workspace before THIS call would make that case invisible to the fence.
564
+ const scope = notionDeliveryScopeVerdict(payloadWorkspaceId, pageId, gate.credentialId, deps.domains);
565
+ if (!scope.ok) {
566
+ return { status: 200, body: "ok", action: "scope-refused", detail: scope.reason, signed, credentialId };
567
+ }
568
+
569
+ // 8c. THE PARTITION'S WORKSPACE — CONFIG'S WORD, NOT THE PAYLOAD'S (OPE-536, completed by
570
+ // OPE-254 §2). 8b has now cleared the domain boundary; this step fixes a narrower, purely
571
+ // app-layer bug that clearing it does not touch.
572
+ //
573
+ // The surface key's workspace segment used to be whatever the payload said, and nothing
574
+ // authenticates that. A stale, mistyped or forged workspace beside an honest page composed a
575
+ // SECOND surface key (`notion/<wrong-workspace>/<page>`) beside the honest one, splitting one
576
+ // page's session + memory history across two partitions with no domain boundary ever crossed
577
+ // to explain it.
578
+ //
579
+ // `notionReachWorkspace` answers with the workspace segment of the `reach` entry that MATCHED
580
+ // — on both legs, exact and container. Notion returns no workspace id on any object, so the
581
+ // only place an operator can ever have read one is a real delivery for that page: the recorded
582
+ // segment IS the real workspace. The container leg is decidable because a domain may reach at
583
+ // most one Notion workspace BY CONTAINER (memory/domain.ts `partitionTenantProblems`, enforced
584
+ // at config load), so the container that swept a page in names exactly one workspace.
585
+ //
586
+ // 🔴 WHAT EACH LEG ACTUALLY BUYS, because the two are not the same size. On the EXACT leg the
587
+ // match is workspace-BLIND, so config and the payload can genuinely disagree and preferring
588
+ // config closes a real split (OPE-536). On the CONTAINER leg admission REQUIRES the payload's
589
+ // workspace to equal the container's own text, so the derived value equals the payload's by
590
+ // construction: what changes is not the value but where it comes from, plus the config rule
591
+ // above that makes "which workspace?" have one answer per domain (OPE-537 §1).
592
+ //
593
+ // Undefined means the page reaches NO domain — the one case config cannot answer, and the
594
+ // forged-workspace case on a container-reached page lands here rather than in the domain. The
595
+ // payload's value then composes a key that belongs to no domain: no memory, no bundle, and
596
+ // the ambient gate refuses it like any unrouted surface. Inventing the domain's workspace
597
+ // here instead would HAND a forged delivery that domain's brain — the opposite of the fix.
598
+ const workspaceId = notionReachWorkspace(payloadWorkspaceId, pageId, deps.domains) ?? payloadWorkspaceId;
599
+
600
+ const surfaceKey = notionSurfaceKey(workspaceId, pageId);
601
+
602
+ // 8d. THE STOP LEG (OPE-989) — the honest equivalent of the ✋ reaction on this surface.
603
+ //
604
+ // Notion pushes no reaction webhook (comment.created/updated/deleted is the whole comment
605
+ // event catalogue — see channels/notion-stop.ts's header for the vendor evidence), so the
606
+ // nearest honest equivalent is the GitHub answer: a comment whose ENTIRE body is the bare word
607
+ // "stop", read here — before the ambient gate and before any dispatch.
608
+ // `notionCommentIsStopDirective` owns the actor guard (fail-closed on an unresolved bot id, an
609
+ // UNIDENTIFIABLE author, a comment authored by ANY of our integrations, an unresolved sibling,
610
+ // or a declared bot author), so a "stop" from anyone but a resolved, distinct human never
611
+ // reaches `cancelAnchoredTurn`. It reads the SAME own-id set the loop guard does (OPE-1921), so
612
+ // a sibling integration's own "stop" can no longer cancel this integration's turn. Read that
613
+ // function for the case it still cannot separate: a FOREIGN bot, whose comment carries a real
614
+ // id and no `created_by.type`, reads there as a person — the same limit the loop guard has,
615
+ // stated in this file's header.
616
+ //
617
+ // 🔴 IT SUPPRESSES THE REST OF THE PIPELINE FOR ITS OWN DELIVERY, unconditionally on the
618
+ // ambient flag — a stop must not depend on the very feature it is asking to stand down.
619
+ // Falling through to the ambient gate/classify/dispatch below would wake the supervisor on the
620
+ // very comment that asked it to stop.
621
+ //
622
+ // RUNS BEFORE THE AMBIENT GATE ON PURPOSE (unlike steps 9-11), and this is safe: on a domain
623
+ // where ambient is disabled no Notion turn is ever started via a comment at all, so the cancel
624
+ // resolves `no_turn_found` — the same honest answer it would give after the gate, one step
625
+ // later.
626
+ if (notionCommentIsStopDirective(comment, ownBotIds)) {
627
+ const stop = () => routeNotionStopComment(
628
+ { workspaceId, pageId, discussionId: discussionId || undefined, credentialId, requesterId: comment.created_by?.id },
629
+ { attachSession: deps.attachSession },
630
+ );
631
+ const result = deps.timing ? await deps.timing.measure("engagementDedupe", stop) : await stop();
632
+ return {
633
+ status: 200,
634
+ body: "ok",
635
+ action: "stopped",
636
+ detail: result.stopped ? result.outcome : result.reason,
637
+ signed,
638
+ credentialId,
639
+ };
640
+ }
641
+
642
+ // 8e. Classify (self / bot / unresolved-fail-closed / @mention / name-drop / ambient). The loop
643
+ // guard lives here (hardened): an unresolved botUserId DROPS.
644
+ //
645
+ // 🔴 THE COMPUTATION MOVED ABOVE THE AMBIENT GATE; THE DECISION DID NOT (OPE-1501). The gate
646
+ // below needs the ADDRESSING to answer, so the classifier — pure, no I/O, no store — runs
647
+ // first, and its `drop` verdict is still acted on where it always was, one step lower. A
648
+ // delivery whose page is neither addressed nor engaged therefore returns the identical
649
+ // `ambient-disabled` it returned before this seam existed.
650
+ const classification = classifyNotionComment(comment, ownBotIds, botName);
651
+ const seamFacts: NotionSeamFacts = {
652
+ workspaceId,
653
+ pageId,
654
+ ...(comment.created_by?.id ? { principalId: comment.created_by.id } : {}),
655
+ ...(credentialId ? { credentialId } : {}),
656
+ };
657
+ // The seam's READ half, and ONLY a plain (`channel-watch`) comment asks it. An ADDRESSED comment
658
+ // enrols the page instead (step 10c) and can never be a stood-down follow-up, because the address
659
+ // is what lifts a stop; a `drop` verdict — self-authored, bot-authored, or an unresolved bot id —
660
+ // is going nowhere, so paying a registry read for it would buy an answer no branch uses.
661
+ // `authorIsBot` is therefore false by construction here, and it is passed rather than assumed so
662
+ // the loop guard stays readable at the call site.
663
+ const seam = classification.addressing === "channel-watch"
664
+ ? deps.timing
665
+ ? await deps.timing.measure("engagementDedupe", () => resolveNotionEngagement(seamFacts, false, deps.engagement))
666
+ : await resolveNotionEngagement(seamFacts, false, deps.engagement)
667
+ : undefined;
668
+
669
+ // 9. Ambient gate — the WHOLE ambient path is fail-closed OFF unless this doc's domain opts in
670
+ // (DOMAIN-level opt-in, ambientSupervisorEnabled). An unopted doc does nothing (matching the
671
+ // old dead mention-gate's net effect, but now it's an explicit, audited decision).
672
+ //
673
+ // 🔴 IT NOW HAS TWO MORE DOORS, AND BOTH ARE THE §2.5 SEAM (OPE-1501 / OPE-1510).
674
+ // • ADDRESSED, IN A DECLARED DOMAIN. Notion is the only surface whose ADDRESSED leg sat
675
+ // behind the ambient opt-in: the gate returned before the classifier ran, so a page that
676
+ // had not opted into ambient could not be addressed at all — deaf, not merely
677
+ // un-followable, while every other surface answers a mention with no such opt-in. That
678
+ // asymmetry is what made the seam unreachable on an addressed-only page.
679
+ //
680
+ // 🔴 THE DOMAIN CONDITION IS NOT DECORATION. `notionDeliveryScopeVerdict` (step 8b) admits
681
+ // a page NO domain claims — "no claimed domain ⇒ allow" — because it fences claim-reached
682
+ // pages rather than deciding presence. Without this second clause a name-drop on ANY page
683
+ // the integration can see, in no domain at all, would run a turn: a far wider door than
684
+ // this seam asks for. `notionPageDomain` answers the narrow question — does an operator's
685
+ // own config, by roster or by claim, hold this page — and it is the same function step 8c
686
+ // already routes with. So the widening is exactly "a page the operator declared, which has
687
+ // not opted into ambient, can still be addressed".
688
+ // • ENGAGED. A page somebody addressed the agent on wakes on plain follow-ups, which is the
689
+ // whole §2.5 posture and the reason this ticket exists. It needs no domain clause: the
690
+ // engagement row cannot exist unless an addressed comment already passed the door above.
691
+ // Every other comment takes the gate exactly as it always did.
692
+ //
693
+ // 🔴 THE DOMAIN VERDICT IS RESOLVED ONCE, HERE, and travels to the route on the dispatch
694
+ // (`domainVia`). The gate below reads it, and the `notion.dispatched` row reports it — one
695
+ // read, so the row can never disagree with the door that admitted the delivery.
696
+ const pageDomain = notionPageDomain(workspaceId, pageId, deps.domains);
697
+ const addressedInDeclaredDomain = classification.addressing === "direct" && pageDomain.via !== "none";
698
+ if (!deps.ambientEnabled(surfaceKey) && !addressedInDeclaredDomain && seam?.kind !== "deliver") {
699
+ return { status: 200, body: "ok", action: "ambient-disabled", detail: surfaceKey, signed, credentialId };
700
+ }
701
+
702
+ // 10. Act on the classification (self / bot / unresolved-fail-closed).
703
+ if (classification.addressing === "drop") {
704
+ const reason = classification.reason;
705
+ const action = reason.startsWith("self") ? "skipped-self" : reason.startsWith("bot") ? "skipped-bot" : "dropped";
706
+ return { status: 200, body: "ok", action, detail: reason, signed, credentialId };
707
+ }
708
+ // Capture the post-drop narrowing before the later dispatch closure. TypeScript does not retain a
709
+ // property narrowing inside a closure, even though both the object and property are readonly.
710
+ const addressing = classification.addressing;
711
+
712
+ // 10c. THE §2.5 STOP (OPE-1501). A plain comment on a page `stand_down` marked is SUPPRESSED —
713
+ // including where the ambient supervisor is enabled and would otherwise answer it, which is the
714
+ // only thing that makes the verb a stop rather than a note. It is a read and a refusal, so it
715
+ // runs here, above the sibling fetch and the ancestor walk it makes pointless. An ADDRESSED
716
+ // comment never reaches this branch: the address is what lifts a stop.
717
+ //
718
+ // 🔴 THE MARK IS NOT HERE. It is a WRITE, and it belongs below the EXTERNAL-AUTHOR decision,
719
+ // which on this surface is made inside `handleNotionSupervisorDispatch` rather than at the
720
+ // ingress — see that function, and the `markEngaged` note on `NotionSupervisorDeps`. Marking
721
+ // here would let an author the operator excluded enrol a page (and clear a stand-down) by
722
+ // naming the agent on it.
723
+ if (classification.addressing !== "direct" && seam?.kind === "hold" && seam.stopped) {
724
+ return { status: 200, body: "ok", action: "stood-down", detail: seam.anchor, signed, credentialId };
725
+ }
726
+
727
+ // 10b. IS THIS PAGE UNDER A DECLARED TRUST ROOT (OPE-492)?
728
+ //
729
+ // The ancestor walk, run ONCE per ingested event — never per tool call, because the answer is
730
+ // about the PAGE and the page is fixed for the whole delivery. It runs HERE, after the
731
+ // classifier, so the agent's own reply loop (self/bot comments, already dropped above) never
732
+ // pays for it.
733
+ //
734
+ // 🔴 IT CAN ONLY PROMOTE. A matched root stamps `trust_root` on the auth and `bindNotion` binds
735
+ // `internal`; every other verdict stamps nothing, and the binding falls through to the
736
+ // per-surface vouch and then to the fail-closed `external` exactly as before. A deployment that
737
+ // declares no roots gets `no-roots` with ZERO API calls, so this step is free until it is used.
738
+ //
739
+ // 🔴 THE PAGE IS THE FETCHED ONE, NOT THE PAYLOAD'S. `pageId` came from step 8, and the walk
740
+ // runs under the same verified credential — so a forged `data.page_id` cannot name a page inside
741
+ // someone's trusted subtree and inherit its trust.
742
+ //
743
+ // THE WORKSPACE IS THE ROSTER'S WORD ON A ROSTERED PAGE, THE PAYLOAD'S OTHERWISE (step 8c) —
744
+ // and here it does not need to be more. It only SELECTS which root set to compare against
745
+ // (`prepareNotionTrustRoots` matches the workspace segment by exact string, so the spelling
746
+ // used HERE decides whether a declared root is even considered), and a root is matched by its
747
+ // globally-unique Notion page id. So a wrong workspace can point the walk at another
748
+ // workspace's roots — none of which can be an ancestor of this page — or at a workspace with
749
+ // no roots. Either way it can only lose a promotion, never manufacture one.
750
+ //
751
+ // The walk is bounded, cached and fail-closed on every miss (channels/notion-ancestry-trust.ts).
752
+ // `notionParentLink` never throws, so a Notion outage degrades to "not trusted".
753
+ const resolveTrust = () => resolveNotionDeliveryTrust(pageId, workspaceId ?? "", deps.trustRoots, {
754
+ parentOf: (id) => notionParentLink(id, ingressCallOpts(cred, deps)),
755
+ });
756
+ const trust = deps.timing ? await deps.timing.measure("ancestorSiblingTrust", resolveTrust) : await resolveTrust();
757
+
758
+ // 11. Build the wake parts + auth, then dispatch to the standing doc supervisor.
759
+ const triggerText = richTextToPlainText(comment.rich_text);
760
+ const trigger: NotionUntieredWakeMessage = {
761
+ from: "human", // self/bot already dropped in classify
762
+ // 🔴 THE API'S OWN SPELLING OF THE ID, NOT THE WEBHOOK'S (OPE-1847). This value rides to the seat
763
+ // catch-up as `SeatCatchUpRequest.excludeId`, and since the catch-up bound now admits the
764
+ // trigger's whole MINUTE, that id match is the ONLY thing that keeps the wake's own comment out
765
+ // of its own context. The other side of the match is `toNotionCommentSummary`, which reads
766
+ // `comment.id` from `GET /v1/comments`. `commentId` came from the webhook envelope's `entity.id`
767
+ // instead, so the two sides were resolved from different Notion surfaces and nothing proved they
768
+ // spell one uuid the same way. `comment` is the object `retrieveComment` returned above, so
769
+ // taking its id makes both sides the SAME field of the SAME API. The webhook id stays as the
770
+ // fallback: it is what addressed the retrieve, so it is never wrong, only possibly spelled
771
+ // differently.
772
+ id: comment.id ?? commentId,
773
+ authorId: comment.created_by?.id,
774
+ sentAt: comment.created_time ?? new Date().toISOString(),
775
+ text: triggerText.length > 0 ? triggerText : "(the comment had no additional text)",
776
+ // The comment object was fetched under the credential whose secret authenticated this delivery.
777
+ // Keep its block parent distinct from the discussion id: Notion lists comments by BLOCK, while
778
+ // replies and filtering use the DISCUSSION.
779
+ ...(comment.parent?.block_id ? { anchorId: comment.parent.block_id } : {}),
780
+ };
781
+ const loadSiblings = () => loadThreadSiblings(comment, pageId, discussionId, ownBotIds, ingressCallOpts(cred, deps));
782
+ const threadComments = deps.timing
783
+ ? await deps.timing.measure("ancestorSiblingTrust", loadSiblings)
784
+ : await loadSiblings();
785
+ const auth = buildNotionAuth({
786
+ workspaceId,
787
+ pageId,
788
+ discussionId: discussionId || undefined,
789
+ commentId,
790
+ principalId: comment.created_by?.id,
791
+ // The integration this delivery runs under (step 5) rides the auth so every reply for this turn
792
+ // posts under it. Host-set from the HMAC verdict — the comment body cannot reach it.
793
+ credentialId,
794
+ // The matched trust root (step 10b), when the walk found one. Host-set from Notion's own parent
795
+ // chain under the verified credential — the comment body cannot reach it either.
796
+ ...(trust.trusted && trust.matchedRoot ? { trustRoot: trust.matchedRoot } : {}),
797
+ });
798
+ // AUTHORITATIVE addressing stamp for the link-claim hook (identity/binding.ts currentTurnAddressed):
799
+ // a `direct` Notion wake is an ADDRESSED, single-author comment (a name-drop / structured @mention),
800
+ // so `link <code>` claiming may tolerate the leading name-drop token. Notion's ONLY way to address
801
+ // the agent is the name-drop, so without this stamp "oe link ABC…" would never be recognised as a
802
+ // claim (the whole-message anchor rejects the "oe " prefix). Stamped ONLY on `direct` — a judged
803
+ // `channel-watch` wake carries no stamp and stays strict (fail-closed), so the F1 envelope attack is
804
+ // unaffected. The resolved integration name rides along so a name-drop matching the deployment's
805
+ // ACTUAL bot name is stripped even without OE_AGENT_* set. Host-set (§AUTH-ATTRIBUTE PROVENANCE) —
806
+ // built here from the classifier verdict, never from the comment body — so it cannot be forged.
807
+ if (classification.addressing === "direct") {
808
+ auth.attributes[OE_ADDRESSED_ATTR] = "1";
809
+ if (botName && botName.trim().length >= 2) auth.attributes[OE_BOT_NAME_ATTR] = botName.trim();
810
+ }
811
+
812
+ // Engagement calibration (the ambient-everywhere tier dial) — fail-soft to neutral.
813
+ let engagementTier: "aggressive" | "neutral" | "reserved" | undefined;
814
+ try {
815
+ engagementTier = deps.engagementTier?.(surfaceKey);
816
+ } catch {
817
+ engagementTier = undefined;
818
+ }
819
+
820
+ // 12. ONE WAKE PER COMMENT (OPE-1906) — the delivery half of the loop guard.
821
+ //
822
+ // 🔴 THE CLASSIFIER CANNOT SEE THIS AND THE AUDIT CANNOT EITHER. Step 8e answers "whose words
823
+ // are these"; a SECOND delivery of the same person's comment carries the same author and the
824
+ // same text, so it passes every gate above exactly as the first did and wakes a second turn —
825
+ // a second model spend, a second `notion.dispatched` row and a second reply in the discussion.
826
+ // On ship #216 one Notion discussion carrying ONE driver comment produced THREE dispatch rows.
827
+ //
828
+ // IT SITS HERE, AS LATE AS THIS FUNCTION ALLOWS, so every refusal the INGRESS makes — the drop,
829
+ // the ambient gate, the scope fence, the stand-down, the unresolved page — happens before a
830
+ // mark is spent, and a redelivery after the operator opens one of those gates still wakes a
831
+ // turn. Past this line the mark IS spent: the spend cap runs inside the dispatch closure
832
+ // (notion.ts `ingressSpendGate`), so an over-budget delivery marks and then declines — which
833
+ // costs nothing real, because that decline posts a VISIBLE refusal rather than going quiet.
834
+ // `firstNotionDispatchOfComment`
835
+ // states the rest: the mark precedes the wake so two concurrent deliveries cannot both run a
836
+ // turn, and it fails OPEN, so a store outage costs a duplicate turn rather than a deaf page.
837
+ const firstDispatch = () => deps.dispatchGuard
838
+ ? deps.dispatchGuard.claim(commentId)
839
+ : firstNotionDispatchOfComment(seamFacts, commentId, deps.engagement);
840
+ const first = deps.timing ? await deps.timing.measure("engagementDedupe", firstDispatch) : await firstDispatch();
841
+ if (!first) {
842
+ return { status: 200, body: "ok", action: "duplicate-delivery", detail: commentId, signed, credentialId };
843
+ }
844
+
845
+ const dispatch = () => deps.dispatch({
846
+ surfaceKey,
847
+ credentialId,
848
+ addressing,
849
+ pageId,
850
+ discussionId,
851
+ workspaceId: workspaceId ?? null,
852
+ auth,
853
+ trigger,
854
+ threadComments,
855
+ engagementTier,
856
+ // THE SEAM'S OWN VERDICT, carried so the route's `notion.dispatched` row can say what woke this
857
+ // delivery (OPE-1510). `deliver` is the ENGAGED door of the gate above, and it is the only door
858
+ // a plain comment has; an ADDRESSED comment never asks the seam, so it never carries the flag.
859
+ ...(seam?.kind === "deliver" ? { engagedFollowUp: true } : {}),
860
+ // HOW THE OPERATOR'S CONFIG HOLDS THIS PAGE (OPE-1510) — the same verdict the addressed door
861
+ // above gated on. `container` is a CONTAINER entry — the whole workspace, or a same-level
862
+ // page glob (memory/domain.ts) — so it admits a name-drop on a page nobody enumerated;
863
+ // `roster` is a page an operator listed by name. The audit row must tell the two apart,
864
+ // because they are different amounts of declared intent behind the same wake.
865
+ domainVia: pageDomain.via,
866
+ // TWO consumers (see NotionSupervisorDispatch.trust): the `notion.dispatched` audit row, and the
867
+ // single-window privacy floor (`notionSingleWindowPrivacyOk` reads `trust.trusted` as its
868
+ // admission leg, OPE-540) — trimming this field silently drops trust-root pages from the shared
869
+ // window. Omitted on the `no-roots` verdict, so a deployment that declares no trust root writes
870
+ // the same audit row it always did; the floor reads absence as "not internal".
871
+ ...(trust.reason === "no-roots" ? {} : { trust }),
872
+ });
873
+ if (deps.timing) await deps.timing.measure("dispatch", dispatch);
874
+ else await dispatch();
875
+ if (deps.dispatchGuard) await deps.dispatchGuard.complete(commentId);
876
+
877
+ return { status: 200, body: "ok", action: "dispatched", detail: classification.addressing, signed, credentialId };
878
+ }
879
+
880
+ /** {@link resolveDeliveryPage}'s answer. Exactly one field is set: `pageId` when the page is
881
+ * established, `reason` when it is not. A `reason` is a REFUSAL — never a page id the caller may
882
+ * substitute. */
883
+ export interface ResolvedDeliveryPage {
884
+ readonly pageId?: string;
885
+ readonly reason?: string;
886
+ }
887
+
888
+ /**
889
+ * The page a delivery's comment REALLY lives on, established from the comment Notion returned under
890
+ * the verified credential (OPE-496). The webhook's step 8 explains why the payload's own `page_id`
891
+ * may not answer this; this function is the answer that replaces it.
892
+ *
893
+ * Two shapes, one credential:
894
+ * • a PAGE-level comment carries `parent.page_id` — already the answer, and already authenticated
895
+ * (the fetch that returned it ran under the integration whose secret verified the delivery);
896
+ * • an INLINE comment carries `parent.block_id` instead, so the block's owning page is walked under
897
+ * the SAME credential ({@link resolveBlockOwningPage}, bounded at BLOCK_PARENT_MAX_HOPS).
898
+ *
899
+ * Every other outcome is a refusal with a reason: no parent at all, a parent that is neither a page
900
+ * nor a block, or a block whose walk ends anywhere but a page (a database or workspace parent, an id
901
+ * the integration cannot see, or too many hops). The caller must treat a reason as "do nothing" —
902
+ * `resolveBlockOwningPage` already states the same rule for the read fence, and both callers keep it.
903
+ *
904
+ * Exported so the delivery test can drive each shape directly, with the mock fetch counting the
905
+ * block-walk calls.
906
+ */
907
+ export async function resolveDeliveryPage(
908
+ comment: NotionComment,
909
+ opts: NotionCallOpts,
910
+ ): Promise<ResolvedDeliveryPage> {
911
+ const parent = comment.parent;
912
+ if (parent?.page_id) return { pageId: parent.page_id };
913
+ if (parent?.block_id) {
914
+ const owner = await resolveBlockOwningPage(parent.block_id, opts);
915
+ return owner ? { pageId: owner } : { reason: `comment parent block "${parent.block_id}" resolved no owning page` };
916
+ }
917
+ return { reason: "the retrieved comment carries no page or block parent" };
918
+ }
919
+
920
+ /**
921
+ * Best-effort surrounding-conversation context: the OTHER comments in the same discussion thread
922
+ * on this page, as oldest-first {@link NotionUntieredWakeMessage} siblings for the wake envelope's
923
+ * backscroll (re-sent each wake — cheap at Notion volume — so the judge always has full thread
924
+ * context). Any failure yields [] (context is enrichment, never a precondition — same posture as
925
+ * the old renderContext path). The triggering comment is excluded (it's the trigger message).
926
+ * The bot's own prior comments are marked `self` so the judge tells its voice from a human's.
927
+ */
928
+ async function loadThreadSiblings(
929
+ trigger: NotionComment,
930
+ pageId: string,
931
+ discussionId: string,
932
+ own: NotionOwnBotIds,
933
+ opts: NotionCallOpts,
934
+ ): Promise<NotionUntieredWakeMessage[]> {
935
+ try {
936
+ const all = await listBlockComments(trigger.parent?.block_id ?? pageId, opts);
937
+ return all
938
+ .filter((c) => c.id !== trigger.id && (discussionId ? c.discussion_id === discussionId : true))
939
+ .map((c) => {
940
+ // OUR OWN VOICE IS THE DEPLOYMENT'S, NOT ONE INTEGRATION'S (OPE-1921). Compared against the
941
+ // verifying integration's id alone, a reply a SIBLING integration posted into this very
942
+ // discussion was rendered to the judge as another person's words.
943
+ const authorId = c.created_by?.id;
944
+ const isSelf = Boolean(authorId !== undefined && own.all.has(authorId));
945
+ // Only an IDENTIFIED PERSON author carries an id downstream (F3): a bot/integration
946
+ // or an author whose person-ness we can't confirm (created_by.type absent on the REST
947
+ // payload) is left id-less, so wake-trust resolves the sibling `relay` — never a human
948
+ // standing (peer/principal) on an unattributed or machine author. Fail-closed: no
949
+ // positive `type === "person"` ⇒ no id ⇒ relay. The comment still rides as context.
950
+ const isPerson = c.created_by?.type === "person";
951
+ return {
952
+ from: isSelf ? ("self" as const) : ("human" as const),
953
+ id: c.id ?? "",
954
+ authorId: isSelf || !isPerson ? undefined : authorId,
955
+ sentAt: c.created_time ?? "",
956
+ text: richTextToPlainText(c.rich_text),
957
+ };
958
+ })
959
+ .filter((m) => m.text.length > 0);
960
+ } catch {
961
+ return [];
962
+ }
963
+ }