@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,250 @@
1
+ // The comments/inline-discussion read tool for the ambient Notion surface โ€” the SIBLING of
2
+ // read_notion_page (which returns the page BODY). The comment thread you were WOKEN on is already in
3
+ // context; this pulls the OTHER discussions on the page (a second commenter on another block, an
4
+ // older resolved argument the reply needs to reconcile) BY REFERENCE โ€” fetched on demand, never
5
+ // inlined into the wake, so a heavily-annotated page can't blow the backscroll budget.
6
+ //
7
+ // SURFACE GATE + REPLAY DOCTRINE + UNTRUSTED WRAP: identical to read_notion_page.ts โ€” a per-session
8
+ // dynamic resolver gated on `notion โˆˆ sources` (workerChatArmMount) with a per-turn origin fence
9
+ // (assertTurnSurface), a top-level factored defineTool (never an inline literal โ€” the bundler's
10
+ // build-time transform hoists one under a build-order-counter name that shifts across deploys; eve
11
+ // derives a session-scoped step id for a factored, unstamped entry and self-heals it on cold resume,
12
+ // OPE-867), and NO `approval:` (read-only).
13
+ //
14
+ // DOMAIN FENCE (channels/notion-scope.ts, OPE-236): BOTH caller-supplied ids are gated โ€” `page`, and
15
+ // `block`, which addresses a block directly and never touches `page` at all. Fail closed on either,
16
+ // and audit the refusal; the one deployment-wide credential fences the workspace, never the domain.
17
+ //
18
+ // Comment bodies are EXTERNAL, UNTRUSTED content (commenters include workspace guests / externals),
19
+ // so the whole rendered listing is wrapped through ingestUntrusted (toModelOutput) โ€” the model must
20
+ // treat comment text as DATA, not instructions. Author ids are surfaced as DATA too, never as a
21
+ // trust signal.
22
+ //
23
+ // ๐Ÿ”ด THIS TOOL IS THE DELEGATED WORKER'S ONLY (OPE-1014). It has ONE mount arm โ€” the chat arm โ€” and
24
+ // that arm now mounts on the `task` grain alone (access/source-scoped-mount.ts `workerChatArmMount`).
25
+ // A conversational Notion seat reads the same comment layer through `read_channel` (channels/
26
+ // notion-read.ts), which sweeps every discussion on the page headed by its anchor block, a SUPERSET
27
+ // of what this tool returns, and `read_thread`, which reads the one discussion the turn is in. Since
28
+ // OPE-1039 the generic pair carries the OPE-1026 dated-read framing too โ€” the `readAt` stamp, the
29
+ // per-line age and the HISTORY marker that stops the model reading a stale comment's claim as the
30
+ // page's present state โ€” so the seat loses no safety framing by taking the generic route.
31
+ //
32
+ // The worker kept it because it had no substitute: access/worker-seat.ts withholds `read_channel` /
33
+ // `read_thread` from every delegated worker. That is no longer the whole story (OPE-1064): `WORK_ROLES`
34
+ // now gives Notion a CONTAINER row too, so a work-granted domain's delegated worker also holds the
35
+ // generic `read_work_channel` / `read_work_thread` pair, which the paragraph above already says
36
+ // SUPERSEDES this tool's own read (a superset via `read_channel`'s discussion sweep). This tool has
37
+ // no work arm of its own to fall back on, so the `task` grain is still its whole mount โ€” it stays
38
+ // live for now because retiring a shipped tool is its own reviewed change (filed on OPE-1064), not
39
+ // a side effect of adding the neutral path beside it.
40
+
41
+ import { defineDynamic, defineTool } from "eve/tools";
42
+ import { z } from "zod";
43
+ import { readNotionComments, resolveBlockOwningPage, resolveNotionCredential, type NotionCommentSummary } from "../channels/notion-api.js";
44
+ import { notionCallerCredential } from "../channels/notion-credentials.js";
45
+ import { assertNotionActingIdentity } from "../channels/notion-acting-identity.js";
46
+ import { assertNotionBlockAllowed, assertNotionPageAllowed } from "../channels/notion-scope.js";
47
+ import {
48
+ ingestUntrustedModelOutput,
49
+ assertTurnSurface,
50
+ TurnOriginFenceError,
51
+ auditOriginFenceRefusal,
52
+ bindFrom,
53
+ workerChatArmMount,
54
+ getConfig,
55
+ preRegisterDynamicEntry,
56
+ resolvePartition,
57
+ declaredWorkspaceTrust,
58
+ ageAtRead,
59
+ type SurfaceTrust,
60
+ type BindableContext,
61
+ } from "@open-everywhere/core/agent/adapter-api.js";
62
+
63
+ const MAX_COMMENTS = 100;
64
+ const MAX_BLOCK_SCANS = 80;
65
+
66
+ /** Render the flattened comments into a human-readable, per-discussion listing. The WHOLE string is
67
+ * wrapped untrusted by toModelOutput, so bodies + author ids inside it are DATA.
68
+ *
69
+ * OPE-1026 โ€” each line carries its age at the moment of the read (see {@link ageAtRead}), the same
70
+ * fact `read_linear_issue.ts` renders for Linear comments: the discriminator between "an old comment
71
+ * said X" and "X is true now". `readAt` is optional so a stored output from before this field
72
+ * existed still renders โ€” with no invented age. */
73
+ function renderComments(comments: readonly NotionCommentSummary[], readAt?: string): string {
74
+ if (comments.length === 0) return "";
75
+ // Group by anchor (block/page) โ†’ each anchor is a distinct inline discussion.
76
+ const byAnchor = new Map<string, NotionCommentSummary[]>();
77
+ for (const c of comments) {
78
+ const key = c.anchorId ?? "(unknown)";
79
+ let list = byAnchor.get(key);
80
+ if (!list) {
81
+ list = [];
82
+ byAnchor.set(key, list);
83
+ }
84
+ list.push(c);
85
+ }
86
+ const sections: string[] = [];
87
+ for (const [anchor, list] of byAnchor) {
88
+ const disc = list.find((c) => c.discussionId)?.discussionId;
89
+ const head = `discussion on block ${anchor}${disc ? ` (discussion_id ${disc})` : ""}:`;
90
+ const lines = list.map((c) => {
91
+ const who = c.authorId ? `${c.authorKind ?? "user"} ${c.authorId}` : "unknown author";
92
+ const when = c.createdTime ? ` at ${c.createdTime}` : "";
93
+ const age = ageAtRead(c.createdTime, readAt);
94
+ const old = age ? ` (${age} before this read)` : "";
95
+ return ` - ${who}${when}${old}: ${c.text}`;
96
+ });
97
+ sections.push([head, ...lines].join("\n"));
98
+ }
99
+ return sections.join("\n\n");
100
+ }
101
+
102
+ // FACTORED + PRE-REGISTERED (see read_notion_page.ts / access/window-gated.ts REPLAY DOCTRINE).
103
+ // Exported for the unit test (its toModelOutput = the untrusted-wrap assertion).
104
+ export const readNotionCommentsEntry = defineTool({
105
+ description:
106
+ "Read the comments / inline discussions on a Notion page (this doc, or another page shared with " +
107
+ "the integration). Use it when a reply needs OTHER discussions on the page โ€” a comment on a " +
108
+ "different block, or an older thread โ€” beyond the one you were woken on (already in context). " +
109
+ "Pass `block` to read just one block's inline discussion. Bounded; truncates a heavily-commented page.",
110
+ inputSchema: z.object({
111
+ page: z
112
+ .string()
113
+ .min(1)
114
+ .optional()
115
+ .describe("The Notion page id whose comments to read. Defaults to THIS doc โ€” the page the comment you're on lives in."),
116
+ block: z
117
+ .string()
118
+ .min(1)
119
+ .optional()
120
+ .describe("Read ONLY this block's inline discussion (a block/page id), instead of sweeping the whole page."),
121
+ }),
122
+ outputSchema: z.object({
123
+ page: z.string(),
124
+ discussions: z.number(),
125
+ comments: z.number(),
126
+ blocksScanned: z.number(),
127
+ truncated: z.boolean(),
128
+ text: z.string(),
129
+ // OPE-1026 โ€” the moment the comments below were fetched, stamped by `execute` and carried to
130
+ // `toModelOutput`. Optional because `toModelOutput` also runs over outputs stored before this
131
+ // field existed; the render drops the read-time line rather than inventing one (see
132
+ // read_linear_issue.ts's `readAt`, the pattern this copies).
133
+ readAt: z.string().optional(),
134
+ // OPE-331 โ€” the bound workspace's declared tier, carried execute โ†’ toModelOutput. Same shape and
135
+ // same reasoning as read_notion_page's field; model-invisible behind the authored projection.
136
+ workspaceTrust: z.enum(["internal", "public"]).optional(),
137
+ }),
138
+ // NO `approval:` โ€” read-only; a dynamic-tool approval fn fails a resumed session's replay lookup
139
+ // (same pin read_notion_page / access/window-gated.ts document). Absent approval === never() is correct.
140
+ async execute(input, ctx2) {
141
+ // Per-turn surface fence (finding 1b): a non-Notion-origin turn on a unified window must not read
142
+ // Notion comments even though the tool is armed for the session.
143
+ // OPE-732: the fence's refusal is audited here (see read_notion_page.ts's note).
144
+ try {
145
+ assertTurnSurface(ctx2 as BindableContext, "notion");
146
+ } catch (err) {
147
+ if (err instanceof TurnOriginFenceError) await auditOriginFenceRefusal(ctx2 as BindableContext, "notion");
148
+ throw err;
149
+ }
150
+ // Default to THIS session's page (bindFrom(ctx).surface = the page id for Notion).
151
+ const page = input.page ?? bindFrom(ctx2 as BindableContext).surface;
152
+ if (!page) throw new Error("No Notion page id available for this session โ€” pass `page` explicitly.");
153
+ // DOMAIN FENCE (OPE-236) โ€” see read_notion_page.ts and channels/notion-scope.ts. The one
154
+ // deployment-wide integration key bounds the WORKSPACE, not the domain, so the domain allowlist
155
+ // is ours to enforce: fail closed on an unresolvable page, audit every refusal.
156
+ const domains = getConfig().loadDomains();
157
+ await assertNotionPageAllowed(ctx2 as BindableContext, page, domains);
158
+ // OPE-467 / OPE-499 โ€” the carried delivery credential decides, when one is present; the CALLING
159
+ // domain's own binding is the fallback (see read_notion_page.ts / notion-credentials.ts).
160
+ // OPE-505: `cred` is the key AND its endpoint rules; spreading it carries the fence into every
161
+ // REST call below (notion-api.ts funnels them all through one guarded fetch).
162
+ // ๐Ÿ”ด OPE-1525 โ€” THE OWN-ACCOUNT GATE, on the line below's behalf. `notionCallerCredential`
163
+ // resolves an `install`-family key, so a member who asked to act with THEIR OWN Notion account
164
+ // has no answer here. This tool is the DELEGATED WORKER's own arm โ€” the grain the live
165
+ // substitution ran in โ€” so leaving it ungated while `read_notion_page` is gated would move the
166
+ // defect one verb along. The gate refuses that turn and writes the `connector.acting_identity`
167
+ // row either way; see channels/notion-acting-identity.ts.
168
+ await assertNotionActingIdentity(ctx2 as BindableContext, "read_notion_comments");
169
+ const cred = await resolveNotionCredential(notionCallerCredential(ctx2 as BindableContext, domains));
170
+ if (!cred) throw new Error("no value stored under db:notion:channel-key โ€” cannot read the comments.");
171
+ // `block` NEEDS ITS OWN GATE: the drill-in addresses the block directly and never touches `page`
172
+ // (notion-api.ts readNotionComments returns early on it), so fencing `page` alone would leave the
173
+ // whole fence bypassable in one argument. Costs one `GET /v1/blocks/{id}` only when the block
174
+ // isn't already in scope by id โ€” the common case (a top-level block of this very page) still
175
+ // pays it, which is the honest price of not trusting a caller-supplied id.
176
+ if (input.block) {
177
+ await assertNotionBlockAllowed(ctx2 as BindableContext, input.block, domains, (id) => resolveBlockOwningPage(id, { ...cred }));
178
+ }
179
+ const res = await readNotionComments(page, {
180
+ ...cred,
181
+ block: input.block,
182
+ maxComments: MAX_COMMENTS,
183
+ maxBlockScans: MAX_BLOCK_SCANS,
184
+ });
185
+ // OPE-1026 โ€” the moment the fetch above returned. Stamped AFTER the fetch, so it never claims a
186
+ // read is fresher than it is (see read_linear_issue.ts's `readAt`).
187
+ const readAt = new Date().toISOString();
188
+ // OPE-331 โ€” resolve the workspace tier where a ctx exists; fail-soft to absent (the table's
189
+ // pre-existing fail-closed answer).
190
+ let workspaceTrust: SurfaceTrust | undefined;
191
+ try {
192
+ workspaceTrust = declaredWorkspaceTrust(resolvePartition(ctx2 as BindableContext).workspaceKey);
193
+ } catch {
194
+ workspaceTrust = undefined;
195
+ }
196
+ return {
197
+ page,
198
+ discussions: res.discussionCount,
199
+ comments: res.comments.length,
200
+ blocksScanned: res.blocksScanned,
201
+ truncated: res.truncated,
202
+ text: renderComments(res.comments, readAt),
203
+ readAt,
204
+ ...(workspaceTrust !== undefined ? { workspaceTrust } : {}),
205
+ };
206
+ },
207
+ toModelOutput(output) {
208
+ const header =
209
+ output.comments === 0
210
+ ? `Notion page ${output.page} has no readable comments.`
211
+ : `Notion page ${output.page} โ€” ${output.comments} comment(s) across ${output.discussions} discussion(s):`;
212
+ const suffix = output.truncated ? "\n\n[truncated โ€” the page has more comments/blocks than shown]" : "";
213
+ // OPE-1026 โ€” THE PROVENANCE LINE, only when there is comment text to date (Linear's pattern,
214
+ // ยงOPE-1012): the comments above are dated against THIS read, and a comment's claim about status
215
+ // or approval is HISTORY, not the page's current state. `read_notion_page` is the tool that reads
216
+ // the page's current content โ€” this one never claims to.
217
+ const asOf =
218
+ output.comments > 0 && output.readAt
219
+ ? `\n\nRead live at ${output.readAt}: the comments above are HISTORY, each dated against this read. A comment's claim about status, approval or progress is not the page's current state โ€” read the page itself (read_notion_page) for that.`
220
+ : "";
221
+ const body = output.comments === 0 ? header : `${header}\n${output.text}${suffix}${asOf}`;
222
+ // OPE-324 โ€” `workspaceTrust` (plumbed OPE-331) is the DECISION here, same route and same meaning
223
+ // as read_notion_page's (see its note): a vouched-internal workspace fences without tainting, an
224
+ // unvouched one falls closed and taints, and the `assumed` flag says the operator never vouched
225
+ // it rather than that OE could not look.
226
+ return ingestUntrustedModelOutput(body, {
227
+ source: `notion comments ${output.page}`,
228
+ ingestion: {
229
+ kind: "notion",
230
+ label: "read_notion_comments",
231
+ ...(output.workspaceTrust !== undefined ? { workspaceTrust: output.workspaceTrust } : {}),
232
+ },
233
+ });
234
+ },
235
+ });
236
+ preRegisterDynamicEntry("read_notion_comments", "_single", readNotionCommentsEntry);
237
+
238
+ export default defineDynamic({
239
+ events: {
240
+ // session.started ONLY: a session runs on exactly one surface (see read_notion_page.ts).
241
+ "session.started": (_event, ctx) => {
242
+ // THE CHAT ARM (ยงA5), narrowed to the DELEGATED-WORKER grain by OPE-1014: mount iff `notion โˆˆ`
243
+ // this window's sources AND the grain is `task`. This tool has NO work arm, so `task` is its whole
244
+ // mount: a conversational Notion seat reads the same comment layer through the generic
245
+ // `read_channel` / `read_thread`, which sweep a superset of what this tool returns.
246
+ if (!workerChatArmMount(ctx as BindableContext, "notion", () => getConfig().loadDomains())) return null;
247
+ return readNotionCommentsEntry;
248
+ },
249
+ },
250
+ });
@@ -0,0 +1,351 @@
1
+ // The doc-body read tool for the ambient Notion surface (notion-ambient-surface-design.md
2
+ // Step 0). The ambient supervisor judges + replies off the COMMENT context (a comment usually
3
+ // anchors to specific text); when a reply needs the doc's own prose โ€” to answer a question about
4
+ // the page, or to catch a factual problem in it โ€” the agent pulls the body with this tool. Body
5
+ // BY REFERENCE (chat-vs-ticketing-and-tool-uniformity.md rec #3): the doc is fetched on demand,
6
+ // NEVER inlined into the wake, so a big page can't blow the backscroll budget.
7
+ //
8
+ // SURFACE GATE: this reads a *Notion* page, so it only makes sense on a Notion session. Authored
9
+ // as a per-session DYNAMIC resolver (defineDynamic) that returns the real tool only when the
10
+ // session platform is `notion`, and `null` (the tool is absent, not merely throwing) elsewhere โ€”
11
+ // the exact discipline access/source-scoped-mount.ts states, so a Slack/Discord agent never sees it.
12
+ //
13
+ // TWO PLANES SINCE OPE-913, and they differ in mount, fence and CREDENTIAL:
14
+ // โ€ข CHAT plane โ€” a Notion-origin turn reads its own wake container's page on the chat credential
15
+ // (`channel-key`). Narrowed to the DELEGATED-WORKER grain by OPE-1014 โ€” see the note below.
16
+ // โ€ข WORK plane โ€” any session whose DOMAIN declares a Notion work credential reads that domain's
17
+ // pages, from any surface, on the WORK credential. This is what makes a Slack-woken delegated
18
+ // worker able to ground itself in the spec it was told to build from (the OPE-913 soak
19
+ // finding). A domain that declares no work credential holds no grant and sees no change.
20
+ // channels/notion-work.ts owns the grant decision; both planes fence the target page to the
21
+ // caller's own domain, and neither can read a page the domain does not own.
22
+ //
23
+ // ๐Ÿ”ด THE CHAT ARM IS THE DELEGATED WORKER'S (OPE-1014), AND THE OPE-988 RULING IS WHY IT IS SAFE TO
24
+ // NARROW. Tommy's ruling in channels/notion-read.ts states that PAGE CONTENT STAYS OUT OF THE CHAT
25
+ // PLANE, and it names the seat's own route in the same breath: "a seat that needs the document
26
+ // itself uses the GRANT-MOUNTED `read_notion_page` or delegates to a worker". Both of those survive
27
+ // this change untouched โ€” the WORK arm above is grain-blind, and a worker holds the chat arm. What
28
+ // goes is the third route the ruling never named: an UNGRANTED conversational Notion seat reading
29
+ // page prose off the chat credential. That seat now delegates, exactly as the ruling says.
30
+ //
31
+ // The worker keeps the arm for now, though it is no longer the ONLY route (OPE-1064): `WORK_ROLES`
32
+ // gives Notion a CONTAINER row too (the same "team-key" role its entity row already names), so a
33
+ // work-granted domain's delegated worker also holds the generic `read_work_channel` /
34
+ // `read_work_thread` pair โ€” access/worker-seat.ts still withholds `read_channel` / `read_thread`
35
+ // from it, but the neutral pair now covers what only this vendor arm covered before. The two are
36
+ // NOT identical: this tool returns the whole page (title + body) in one read, the generic pair
37
+ // splits root/thread the Slack way. Retiring this arm in favour of the generic pair entirely is a
38
+ // follow-up (filed on OPE-1064), not this change โ€” it changes a shipped tool's shape and deserves
39
+ // its own review.
40
+ //
41
+ // DOMAIN FENCE: the `page` argument is a caller-supplied id, and the one deployment-wide Notion
42
+ // credential cannot tell one domain's pages from another's โ€” so which pages this session may read is
43
+ // OURS to decide, in channels/notion-scope.ts (OPE-236). Fail closed, and every refusal is audited.
44
+ //
45
+ // Output is wrapped untrusted (toModelOutput โ†’ ingestUntrusted): a page body is written by
46
+ // arbitrary workspace members, so the model must treat it as DATA, not instructions.
47
+
48
+ import { defineDynamic, defineTool } from "eve/tools";
49
+ import { z } from "zod";
50
+ import { readNotionPage, readNotionPageIdentity, resolveBlockOwningPage, resolveNotionCredential } from "../channels/notion-api.js";
51
+ import { notionCallerCredential } from "../channels/notion-credentials.js";
52
+ import { assertNotionActingIdentity } from "../channels/notion-acting-identity.js";
53
+ import { assertNotionPageAllowed, assertNotionPageAllowedOrResolveBlock, assertNotionWorkPageAllowed } from "../channels/notion-scope.js";
54
+ import { notionWorkGrant, notionWorkMount, notionWorkspacesOf, type NotionWorkGrant } from "../channels/notion-work.js";
55
+ import {
56
+ ingestUntrustedModelOutput,
57
+ assertTurnSurface,
58
+ TurnOriginFenceError,
59
+ auditOriginFenceRefusal,
60
+ bindFrom,
61
+ workerChatArmMount,
62
+ getConfig,
63
+ preRegisterDynamicEntry,
64
+ resolvePartition,
65
+ declaredWorkspaceTrust,
66
+ noteAuthoredRecordRead,
67
+ type SurfaceTrust,
68
+ type BindableContext,
69
+ } from "@open-everywhere/core/agent/adapter-api.js";
70
+
71
+ const MAX_CHARS = 20_000;
72
+ const MAX_BLOCKS = 200;
73
+
74
+ // FACTORED (see access/window-gated.ts's REPLAY DOCTRINE): a top-level defineTool const, never an
75
+ // inline literal in the handler โ€” eve's bundler transform hoists an inline one under a
76
+ // build-order-counter name that shifts across deploys. eve derives a session-scoped step id for a
77
+ // factored, unstamped entry and self-heals it on cold resume (OPE-867). Exported for the read-scope
78
+ // test, which drives execute() to prove the DOMAIN FENCE is actually
79
+ // WIRED here โ€” a fence the tool forgets to call is worth nothing, and the module test alone would
80
+ // not notice its removal.
81
+ export const readNotionPageEntry = defineTool({
82
+ description:
83
+ "Read the text of a Notion page (this doc, or another page shared with the integration). " +
84
+ "Use it when a reply needs the doc's own content โ€” to answer a question about the page or " +
85
+ "catch a problem in it. Returns the page's top-level text (truncated if very long); the " +
86
+ "comment thread you were woken on is already in your context. It reads under the shared " +
87
+ "team integration, never under the asker's own Notion account, so it sees what the team " +
88
+ "integration was shared and not what they can see.",
89
+ inputSchema: z.object({
90
+ page: z
91
+ .string()
92
+ .min(1)
93
+ .optional()
94
+ .describe("The Notion page id to read (e.g. a UUID). Defaults to THIS doc โ€” the page the comment you're on lives in."),
95
+ }),
96
+ outputSchema: z.object({
97
+ page: z.string(),
98
+ // OPE-1714 โ€” the page's own title and Notion url, from a separate GET on the page object
99
+ // (the body read returns blocks alone). The model gets a name and a link it can hand a
100
+ // human instead of a bare uuid. Optional both ways: a stored output from before these
101
+ // fields existed still renders, and the identity fetch is fail-soft, so a page whose
102
+ // object could not be read renders exactly as it did before. UNTRUSTED โ€” a workspace
103
+ // member chooses the title, and toModelOutput wraps the whole render accordingly.
104
+ title: z.string().optional(),
105
+ url: z.string().optional(),
106
+ blocks: z.number(),
107
+ truncated: z.boolean(),
108
+ text: z.string(),
109
+ // OPE-1026 โ€” the moment the fetch below returned, stamped by execute and carried to
110
+ // toModelOutput. Optional for the same reason `read_linear_issue.ts`'s `readAt` is: a
111
+ // stored output from before this field existed still renders, with no invented read time.
112
+ readAt: z.string().optional(),
113
+ // OPE-331 โ€” the BOUND WORKSPACE's declared tier, carried from execute (which has a ctx) to
114
+ // toModelOutput (which does not). It is the input the source-tier table's `notion` row needs, and
115
+ // the reason every row this seam produced read `assumed`. Model-INVISIBLE: an authored
116
+ // toModelOutput means the projection is what the model sees, never this object. Absent when
117
+ // the operator has not vouched the workspace โ€” see declaredWorkspaceTrust for why silence
118
+ // stays absent rather than becoming `"public"`.
119
+ workspaceTrust: z.enum(["internal", "public"]).optional(),
120
+ }),
121
+ // NO `approval:` โ€” a dynamic-tool approval fn only registers at session.started resolve
122
+ // time, so a resumed session's replay lookup fails and eve kills the turn (the same pin
123
+ // access/window-gated.ts documents). Read-only, so absent approval === never() is correct.
124
+ async execute(input, ctx2) {
125
+ const domains = getConfig().loadDomains();
126
+ // Per-turn surface fence (finding 1b): on a unified session a non-Notion-origin turn
127
+ // must not read Notion pages on the CHAT plane, even though the tool is armed for the
128
+ // session. OPE-732: the fence's refusal is audited here โ€” assertTurnSurface itself stays
129
+ // a pure, synchronous, store-free check (see identity/binding.ts).
130
+ //
131
+ // OPE-913 โ€” THE WORK PLANE IS THE ONE WAY PAST IT. A non-Notion-origin turn is admitted
132
+ // only when the caller's DOMAIN holds the Notion work grant (channels/notion-work.ts).
133
+ // That is not a hole in the fence: the grant is a reviewed config declaration naming the
134
+ // credential the read runs under, and the target page is still fenced to the caller's own
135
+ // domain below (assertNotionWorkPageAllowed). With no grant the refusal is byte-identical
136
+ // to before โ€” same audit row, same thrown error.
137
+ let workGrant: NotionWorkGrant | undefined;
138
+ try {
139
+ assertTurnSurface(ctx2 as BindableContext, "notion");
140
+ } catch (err) {
141
+ if (!(err instanceof TurnOriginFenceError)) throw err;
142
+ workGrant = notionWorkGrant(ctx2 as BindableContext, domains);
143
+ if (!workGrant) {
144
+ await auditOriginFenceRefusal(ctx2 as BindableContext, "notion");
145
+ throw err;
146
+ }
147
+ }
148
+ // Default to THIS session's page (bindFrom(ctx).surface = the page id for Notion โ€” see
149
+ // identity/binding.ts bindNotion). On the WORK plane there is no such default to take:
150
+ // the caller's surface is a Slack channel / a repo / a Linear team, and `bindFrom().surface`
151
+ // would hand a non-Notion id to the Notion API. Require the argument instead of guessing.
152
+ const requestedPage = input.page ?? (workGrant ? undefined : bindFrom(ctx2 as BindableContext).surface);
153
+ if (!requestedPage) {
154
+ throw new Error(
155
+ workGrant
156
+ ? "Pass `page` explicitly โ€” this session was not woken on a Notion page, so there is no page to default to."
157
+ : "No Notion page id available for this session โ€” pass `page` explicitly.",
158
+ );
159
+ }
160
+ // Credential resolved BEFORE the fence (OPE-352): resolving a block/discussion id to its
161
+ // owning page needs a working Notion API call, so the resolver has to be built first. This
162
+ // does NOT reintroduce a network dependency into a plain refusal โ€” when there is no key, the
163
+ // resolver below is a no-op stub (never calls fetch), so an out-of-scope id still refuses
164
+ // with zero network and zero key, exactly as before (pinned in notion-read-scope-test.ts ยง6).
165
+ // OPE-467 / OPE-499 โ€” a delivery-woken session reads under the credential that VERIFIED
166
+ // the delivery (carried on the auth attributes); an agent-initiated session (no delivery)
167
+ // falls back to the CALLING domain's own binding, or the org default when it binds none.
168
+ // Resolved off the caller's OWN surface/context, known before the fence runs (the
169
+ // block-resolution walk below needs a working key to run at all). That is not a weaker
170
+ // check: notion-scope.ts only ever admits the caller's own page or a page in the caller's
171
+ // own domain, so caller and target resolve to the same domain by construction.
172
+ // OPE-505: the key AND its endpoint rules, resolved together and spread together, so the
173
+ // credential's fence reaches every REST call below.
174
+ //
175
+ // OPE-913 โ€” THE WORK PLANE RUNS ON A DIFFERENT CREDENTIAL, and that is the point of the
176
+ // split. `notionCallerCredential` resolves the CHAT key (`channel-key`, the comment
177
+ // surface's plumbing). A work-granted read runs on the credential the DOMAIN declared for
178
+ // its work role, so an organisation can share its chat integration with the comment pages
179
+ // only and its work integration with the page trees the agent may build from.
180
+ //
181
+ // ๐Ÿ”ด ONE VARIABLE FEEDS BOTH THE RESOLVE AND THE FAILURE MESSAGE, deliberately. Naming the
182
+ // credential in a literal string that a second expression resolves is how a test comes to
183
+ // assert a plane it is not actually exercising: the message says "work credential" while
184
+ // the call resolves the chat one, and nothing notices. Interpolating `credentialId` makes
185
+ // the message an OBSERVABLE of the resolution instead of a claim about it.
186
+ //
187
+ // ๐Ÿ”ด OPE-1525 โ€” THE OWN-ACCOUNT GATE RUNS FIRST, ON THE LINE BELOW'S BEHALF. Both arms of
188
+ // the choice below resolve a TEAM key, so a member who asked to act with THEIR OWN Notion
189
+ // account has no answer here โ€” and answering anyway is the defect the ticket traced. The
190
+ // gate refuses that turn (naming the consent route, or the granted tool, or saying plainly
191
+ // that neither exists) and writes the `connector.acting_identity` row either way.
192
+ await assertNotionActingIdentity(ctx2 as BindableContext, "read_notion_page");
193
+ const credentialId = workGrant ? workGrant.credential : notionCallerCredential(ctx2 as BindableContext, domains);
194
+ const cred = await resolveNotionCredential(credentialId);
195
+ // DOMAIN FENCE (OPE-236 + OPE-352). Notion's own enforcement bounds the WORKSPACE โ€” one
196
+ // deployment-wide integration key, identical for every domain โ€” so it cannot tell domain A's
197
+ // pages from domain B's. channels/notion-scope.ts is the allowlist on top: own page always,
198
+ // otherwise caller and target must land in the SAME domain (roster, then a container entry
199
+ // claim), and an unresolvable page is REFUSED, never defaulted readable. The refusal is
200
+ // audited there โ€” the gap this closes was silent.
201
+ //
202
+ // OPE-352: the Notion worker intermittently hands this tool a DISCUSSION BLOCK id instead of
203
+ // the page it lives on, and a block id fails the plain page fence every time (it is never
204
+ // itself rostered/claimed) โ€” a silent-looking `target-unassigned` refusal even when the
205
+ // block's owning page was perfectly in scope. With a working credential, fall back to
206
+ // resolving `requestedPage` as a block id to its parent page (bounded walk, see
207
+ // resolveBlockOwningPage) and fence THAT page through the exact same decision โ€” never a
208
+ // separate, looser check. `page` below is the id actually authorized (the original id, or
209
+ // the resolved parent page when only resolution admitted it) โ€” the content fetch and the
210
+ // reported `page` field both use it, never the raw `requestedPage`.
211
+ //
212
+ // OPE-913 โ€” THE WORK PLANE TAKES THE PLAIN FENCE, deliberately. `notionReadScope` reads the
213
+ // caller's domain out of its own Notion surface key, which a Slack-woken caller does not
214
+ // have, so the work plane fences through `notionWorkScope` instead: the caller's domain
215
+ // comes from whatever surface woke it, and the target must still land in THAT domain. No
216
+ // block-parent resolution hop here โ€” the OPE-352 hop exists because the Notion COMMENT
217
+ // worker is handed discussion-block ids by its own wake, which a cross-surface caller never
218
+ // is. A block id from the work plane refuses, and the refusal says how to pass a page id.
219
+ let page: string;
220
+ if (workGrant) {
221
+ await assertNotionWorkPageAllowed(
222
+ ctx2 as BindableContext,
223
+ requestedPage,
224
+ workGrant.domain,
225
+ domains,
226
+ notionWorkspacesOf(workGrant.domain),
227
+ );
228
+ page = requestedPage;
229
+ } else if (cred) {
230
+ page = await assertNotionPageAllowedOrResolveBlock(ctx2 as BindableContext, requestedPage, domains, (id) => resolveBlockOwningPage(id, { ...cred }));
231
+ } else {
232
+ await assertNotionPageAllowed(ctx2 as BindableContext, requestedPage, domains);
233
+ page = requestedPage;
234
+ }
235
+ if (!cred) {
236
+ throw new Error(
237
+ workGrant
238
+ ? `no value stored for this domain's Notion work credential "${credentialId}" โ€” cannot read the page.`
239
+ : "no value stored under db:notion:channel-key โ€” cannot read the page.",
240
+ );
241
+ }
242
+ const res = await readNotionPage(page, { ...cred, maxChars: MAX_CHARS, maxBlocks: MAX_BLOCKS });
243
+ // OPE-1026 โ€” the moment the fetch above returned. Stamped AFTER the fetch, so it never
244
+ // claims a read is fresher than it is (see read_linear_issue.ts's `readAt`).
245
+ const readAt = new Date().toISOString();
246
+ // OPE-331 โ€” resolve the workspace tier HERE, where a ctx exists, and carry it on the
247
+ // output. Read-only and fail-soft: an unresolvable partition leaves it absent, which is the
248
+ // fail-closed answer the table already had.
249
+ //
250
+ // ๐Ÿ”ด OPE-913 โ€” THE WORK PLANE LEAVES IT ABSENT, AND THAT IS THE WHOLE POINT. This resolves
251
+ // the tier of the CALLER'S OWN partition, which on the chat plane IS the Notion workspace
252
+ // the page lives in. On the work plane the caller is a Slack channel, a repo, a Linear
253
+ // team โ€” so the same line would hand this Notion page the tier the operator vouched for
254
+ // SLACK, and a vouched Slack workspace would silently un-taint a page read from an
255
+ // unvouched Notion workspace. Absent means the read TAINTS (see toModelOutput below), which
256
+ // is the correct answer for a tier we genuinely cannot resolve here.
257
+ let workspaceTrust: SurfaceTrust | undefined;
258
+ if (!workGrant) {
259
+ try {
260
+ workspaceTrust = declaredWorkspaceTrust(resolvePartition(ctx2 as BindableContext).workspaceKey);
261
+ } catch {
262
+ workspaceTrust = undefined;
263
+ }
264
+ }
265
+ // OPE-1714 โ€” the page's own title and url, on the SAME credential and the SAME fenced page
266
+ // id the body read used. Separate call because the block-children endpoint carries neither;
267
+ // fail-soft inside the helper, so a page whose object cannot be read still returns its body.
268
+ const identity = await readNotionPageIdentity(page, { ...cred });
269
+ // OPE-1714 โ€” THE REPLY FOOTER'S RECORD HALF, recorded HERE because here is where the host
270
+ // knows what it fetched. The door writes one reply-provenance entry (`notion`,
271
+ // `read_notion_page`) and decides on its own, from this turn's `page_id` stamp, whether the
272
+ // room already holds this page's id โ€” a page the room never named is recorded WITHOUT its
273
+ // id, so it can never be footed. The title is turn-local and never stored. Never throws.
274
+ noteAuthoredRecordRead(ctx2 as BindableContext, {
275
+ kind: "notion-page",
276
+ id: page,
277
+ // The id the CALLER asked for as well as the one the fence authorized: OPE-352's hop
278
+ // resolves a discussion-block id to its owning page, and the room's stamp names the
279
+ // block. Both name the record this read pulled.
280
+ alsoKnownAs: [requestedPage],
281
+ name: identity.title,
282
+ });
283
+ return {
284
+ page,
285
+ ...(identity.title !== undefined ? { title: identity.title } : {}),
286
+ ...(identity.url !== undefined ? { url: identity.url } : {}),
287
+ blocks: res.blockCount,
288
+ truncated: res.truncated,
289
+ text: res.text,
290
+ readAt,
291
+ ...(workspaceTrust !== undefined ? { workspaceTrust } : {}),
292
+ };
293
+ },
294
+ toModelOutput(output) {
295
+ // OPE-1714 โ€” name the page and link it where the identity fetch answered. `ref` degrades to
296
+ // the bare id, which is exactly what every render said before these fields existed.
297
+ const ref = [output.title, output.url].filter((v) => typeof v === "string" && v.length > 0).join(" โ€” ");
298
+ const named = ref.length > 0 ? `${output.page} (${ref})` : output.page;
299
+ const header = output.text.length === 0 ? `Notion page ${named} has no readable text.` : `Notion page ${named}:`;
300
+ const suffix = output.truncated ? "\n\n[truncated โ€” the page has more content than shown]" : "";
301
+ // OPE-1026 โ€” THE PROVENANCE LINE (the pattern OPE-1012 shipped for Linear, ยงasOf). Only
302
+ // when there is text to date: this is the page's CURRENT content, as of this read โ€” the
303
+ // counterpart to read_notion_comments, whose comments are HISTORY against the same clock.
304
+ const asOf =
305
+ output.text.length > 0 && output.readAt ? `\n\nRead live at ${output.readAt}: the text above is the page's current content, as of that moment.` : "";
306
+ const body = output.text.length === 0 ? header : `${header}\n${output.text}${suffix}${asOf}`;
307
+ // ๐Ÿ”ด OPE-324 โ€” `workspaceTrust` (plumbed OPE-331) IS NOW THE DECISION. A page read from a
308
+ // workspace the operator VOUCHED as internal fences without tainting; an unvouched or
309
+ // public workspace still taints. Tommy ratified that relaxation on 2026-08-09 โ€” a vouched
310
+ // internal Notion workspace is org property with org membership, the same trust basis as a
311
+ // private channel, and the instance-binary rule says so.
312
+ //
313
+ // `toModelOutput` has no ctx to resolve config from, so execute resolves the bound
314
+ // workspace's declared tier and carries it on the output. Absent โ‡’ the table falls closed
315
+ // and the read TAINTS, and the `assumed` flag says why: the operator has never vouched this
316
+ // workspace (agent/identity/surfaces.ts), not that OE could not look.
317
+ return ingestUntrustedModelOutput(body, {
318
+ source: `notion page ${output.page}`,
319
+ ingestion: {
320
+ kind: "notion",
321
+ label: "read_notion_page",
322
+ ...(output.workspaceTrust !== undefined ? { workspaceTrust: output.workspaceTrust } : {}),
323
+ },
324
+ });
325
+ },
326
+ });
327
+ preRegisterDynamicEntry("read_notion_page", "_single", readNotionPageEntry);
328
+
329
+ export default defineDynamic({
330
+ events: {
331
+ // session.started ONLY: a session runs on exactly one surface, so the platform can't change
332
+ // mid-session โ€” resolving once is correct and cheaper (see access/source-scoped-mount.ts).
333
+ "session.started": (_event, ctx) => {
334
+ // THE CHAT ARM (ยงA5, see access/source-scoped-mount.ts), narrowed to the DELEGATED-WORKER grain by
335
+ // OPE-1014: mount iff `notion โˆˆ` this window's sources AND the grain is `task`. The per-turn
336
+ // origin fence in execute (assertTurnSurface) still stops a non-Notion-origin turn reading a
337
+ // page on a multi-source window.
338
+ //
339
+ // OPE-913 โ€” OR the caller's DOMAIN holds the Notion WORK grant. That is the second, independent
340
+ // mount path, and it is GRAIN-BLIND: a Slack-woken delegated worker in a work-granted domain
341
+ // mounts this tool even though `notion โˆ‰ sources` for its window, and so does a granted
342
+ // conversational seat. Without the grant nothing widens โ€” a domain that declares no Notion work
343
+ // credential mounts this tool on its delegated workers only.
344
+ const ok =
345
+ workerChatArmMount(ctx as BindableContext, "notion", () => getConfig().loadDomains()) ||
346
+ notionWorkMount(ctx as BindableContext, () => getConfig().loadDomains());
347
+ if (!ok) return null; // hide unless notion โˆˆ sources, or the domain holds the work grant
348
+ return readNotionPageEntry;
349
+ },
350
+ },
351
+ });
@@ -0,0 +1,23 @@
1
+ import { type BindableContext } from "@open-everywhere/core/agent/adapter-api.js";
2
+ /**
3
+ * Refuse this Notion read when the requester asked to act with their OWN Notion account.
4
+ *
5
+ * Throws the gate's own sentence โ€” which names the consent route when one exists here, says plainly
6
+ * that there is none when it does not, names the granted tool when the account is already lent, and
7
+ * asks which account was meant when one sentence named both. Returns normally on every other turn,
8
+ * which is the ordinary path and is unchanged.
9
+ *
10
+ * ๐Ÿ”ด IT ALWAYS WRITES THE `connector.acting_identity` ROW, INCLUDING ON THE ORDINARY PATH. A trail
11
+ * that records only refusals cannot answer "which account read this page", which is the question
12
+ * OPE-1525 was filed for.
13
+ *
14
+ * ๐Ÿ”ด THE `install` CHECK IS STRUCTURAL, NOT DECORATION. The gate's contract is "refuse on `refusal`,
15
+ * otherwise run under `identity`" โ€” and every caller of this helper goes on to resolve the
16
+ * DEPLOYMENT's credential, which is the `install` identity and the only one it can spend. Reading
17
+ * only the refusal would keep that contract by convention: the day a caller passes
18
+ * `callerCanSpendPersonal`, the gate would return `identity: "human-acting"`, this helper would
19
+ * return normally, and the read would run on the install key โ€” the exact OPE-1525 defect, re-entered
20
+ * through the fix for it. So the precondition is asserted here rather than remembered.
21
+ */
22
+ export declare function assertNotionActingIdentity(ctx: BindableContext, tool: string): Promise<void>;
23
+ //# sourceMappingURL=notion-acting-identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notion-acting-identity.d.ts","sourceRoot":"","sources":["../../../agent/channels/notion-acting-identity.ts"],"names":[],"mappings":"AAwCA,OAAO,EAAuD,KAAK,eAAe,EAAyB,MAAM,4CAA4C,CAAC;AAa9J;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAclG"}