@open-everywhere/provider-notion 0.1.4 → 0.2.1
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.
- package/NOTICE +34 -4
- package/README.md +1 -1
- package/agent/channels/notion-question-return.ts +11 -4
- package/agent/channels/notion-supervisor.ts +2 -2
- package/agent/channels/notion-webhook.ts +1 -1
- package/agent/channels/notion-work-search.ts +3 -0
- package/agent/channels/notion.ts +39 -15
- package/agent/docs-generated.ts +1 -1
- package/dist/agent/channels/notion-question-return.d.ts +0 -1
- package/dist/agent/channels/notion-question-return.d.ts.map +1 -1
- package/dist/agent/channels/notion-question-return.js +11 -3
- package/dist/agent/channels/notion-question-return.js.map +1 -1
- package/dist/agent/channels/notion-supervisor.d.ts +2 -2
- package/dist/agent/channels/notion-supervisor.js +2 -2
- package/dist/agent/channels/notion-webhook.js +1 -1
- package/dist/agent/channels/notion-webhook.js.map +1 -1
- package/dist/agent/channels/notion-work-search.d.ts.map +1 -1
- package/dist/agent/channels/notion-work-search.js +3 -0
- package/dist/agent/channels/notion-work-search.js.map +1 -1
- package/dist/agent/channels/notion.d.ts +1 -2
- package/dist/agent/channels/notion.d.ts.map +1 -1
- package/dist/agent/channels/notion.js +34 -13
- package/dist/agent/channels/notion.js.map +1 -1
- package/dist/agent/docs-generated.js +1 -1
- package/dist/agent/docs-generated.js.map +1 -1
- package/package.json +4 -4
package/NOTICE
CHANGED
|
@@ -8,10 +8,40 @@ This product is licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
|
8
8
|
Built on Vercel Eve (https://github.com/vercel/eve), licensed under the
|
|
9
9
|
Apache License, Version 2.0.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Modifications to eve
|
|
14
|
+
|
|
15
|
+
The @open-everywhere/core package distributes patches/eve+0.71.0.patch. That
|
|
16
|
+
patch file is a modification of eve 0.71.0. Vercel licenses eve 0.71.0 under the
|
|
17
|
+
Apache License, Version 2.0. The copyright notice of eve 0.71.0 reads
|
|
18
|
+
"Copyright 2026 Vercel, Inc. and contributors". The Open Everywhere authors made
|
|
19
|
+
the changes in the patch file.
|
|
20
|
+
|
|
21
|
+
The patch modifies four files of eve 0.71.0:
|
|
22
|
+
|
|
23
|
+
dist/src/execution/web-fetch/request.js
|
|
24
|
+
dist/src/harness/tool-loop.js
|
|
25
|
+
dist/src/internal/authored-package-boundary.js
|
|
26
|
+
dist/src/protocol/message.d.ts
|
|
27
|
+
|
|
28
|
+
Open Everywhere does not ship the eve package. eve is a peer dependency, so the
|
|
29
|
+
consumer installs eve 0.71.0 from the registry. The patch file carries the
|
|
30
|
+
modified portions of eve. The oe-eve-patch command, which
|
|
31
|
+
@open-everywhere/core ships, applies them to the eve that the consumer
|
|
32
|
+
installed. The command patches no file outside the consumer's own project. To
|
|
33
|
+
restore an eve that holds an older Open Everywhere patch, it also writes to npm's
|
|
34
|
+
cache and to a temporary directory.
|
|
35
|
+
|
|
36
|
+
The attribution notice of eve 0.71.0 follows, as section 4(d) of the Apache
|
|
37
|
+
License, Version 2.0 requires. The notices for eve's bundled code-mode runtime
|
|
38
|
+
are not reproduced, because the patch modifies no part of that runtime.
|
|
39
|
+
|
|
40
|
+
eve
|
|
41
|
+
Copyright 2026 Vercel, Inc. and contributors
|
|
42
|
+
|
|
43
|
+
This product includes software developed at Vercel, Inc.
|
|
44
|
+
(https://vercel.com/).
|
|
15
45
|
|
|
16
46
|
---
|
|
17
47
|
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ asserts that **every** one of them is absent from `packages/core/agent`. For Not
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
|
-
npm i @open-everywhere/core @open-everywhere/provider-notion eve@0.
|
|
19
|
+
npm i @open-everywhere/core @open-everywhere/provider-notion eve@0.71.0
|
|
20
20
|
npx oe-eve-patch
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
// THE NATIVE ROUTE'S RETURN LEG (OPE-1305) — how a discussion's next comment
|
|
2
|
-
//
|
|
1
|
+
// THE NATIVE ROUTE'S RETURN LEG (OPE-1305) — how a discussion's next comment answers the session's
|
|
2
|
+
// pending question by request id, after the room and floor check.
|
|
3
|
+
//
|
|
4
|
+
// 🔴 WHAT IT SERVES NOW (OPE-2351). It was built for a DELEGATED WORKER's question, as told below. A
|
|
5
|
+
// Notion session no longer has children: only the work session holds `agent`. The question it serves
|
|
6
|
+
// is the session's OWN. The leg delivers an ADMITTED answer from the asking discussion by request id,
|
|
7
|
+
// and a guest's answer carries the guest's nonmember stamp (OPE-2348, core hooks/held-question.ts).
|
|
8
|
+
// ⚠️ IT IS NOT A FENCE. A comment from another discussion on the page, or from a commenter the floor
|
|
9
|
+
// refuses, falls to a plain send with no `__eveInputText` override, and eve may take it as the answer.
|
|
10
|
+
// The unified channel closes that with `ordinaryWakeSendOptions`; this route does not. The history
|
|
11
|
+
// below is kept for the mechanism.
|
|
3
12
|
//
|
|
4
13
|
// WHAT WAS BROKEN, AND WHY IT WAS NOT FIXED BY THE HANDLER THAT POSTS THE QUESTION. OPE-1275 gave
|
|
5
14
|
// Notion's channel a real `input.requested` handler: an `ask_question` now lands in the discussion
|
|
@@ -130,7 +139,6 @@ export interface NotionInboundAnswer {
|
|
|
130
139
|
readonly requestId: string;
|
|
131
140
|
readonly floor: string;
|
|
132
141
|
readonly wakeId?: string;
|
|
133
|
-
readonly taskId?: string;
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
/**
|
|
@@ -207,7 +215,6 @@ export async function resolveNotionInboundAnswer(input: {
|
|
|
207
215
|
requestId: routing.requestId,
|
|
208
216
|
floor: routing.floor,
|
|
209
217
|
...(routing.wakeId !== undefined ? { wakeId: routing.wakeId } : {}),
|
|
210
|
-
...(routing.taskId !== undefined ? { taskId: routing.taskId } : {}),
|
|
211
218
|
};
|
|
212
219
|
} catch {
|
|
213
220
|
// A decision that threw is a decision we do not have. One ordinary wake, never a lost comment.
|
|
@@ -111,8 +111,8 @@ export interface NotionClassification {
|
|
|
111
111
|
* The Notion synthetic-mention (name-drop) convention. Notion CANNOT @mention an internal
|
|
112
112
|
* integration (notion-webhook.ts — the structured `mentionsUser` path is STRUCTURALLY DEAD for the
|
|
113
113
|
* agent), so a person addresses the doc agent by NAME instead: the deployment's configured names
|
|
114
|
-
* (
|
|
115
|
-
* or the platform-resolved integration name (botName). This is the ONLY ambient-INDEPENDENT directed
|
|
114
|
+
* (the `identity.name` / `identity.shortName` settings; the brand "open everywhere" / "oe" only when
|
|
115
|
+
* no name is configured) or the platform-resolved integration name (botName). This is the ONLY ambient-INDEPENDENT directed
|
|
116
116
|
* input Notion has, so — unlike Slack/GitHub/Linear, where a name-drop stays a judged input because a
|
|
117
117
|
* real @mention is available — here it is promoted to `direct`.
|
|
118
118
|
*
|
|
@@ -802,7 +802,7 @@ async function deliver(
|
|
|
802
802
|
// claim (the whole-message anchor rejects the "oe " prefix). Stamped ONLY on `direct` — a judged
|
|
803
803
|
// `channel-watch` wake carries no stamp and stays strict (fail-closed), so the F1 envelope attack is
|
|
804
804
|
// unaffected. The resolved integration name rides along so a name-drop matching the deployment's
|
|
805
|
-
// ACTUAL bot name is stripped even
|
|
805
|
+
// ACTUAL bot name is stripped even with no `identity.name` configured. Host-set (§AUTH-ATTRIBUTE PROVENANCE) —
|
|
806
806
|
// built here from the classifier verdict, never from the comment body — so it cannot be forged.
|
|
807
807
|
if (classification.addressing === "direct") {
|
|
808
808
|
auth.attributes[OE_ADDRESSED_ATTR] = "1";
|
|
@@ -65,6 +65,9 @@ export const notionWorkSearchAdapter: WorkSearchAdapter = {
|
|
|
65
65
|
id: h.id,
|
|
66
66
|
title: h.title || "(untitled)",
|
|
67
67
|
...(h.url ? { url: h.url } : {}),
|
|
68
|
+
// OPE-2178 — the read road. `read_notion_page` reads a PAGE; a database hit gets no road, and
|
|
69
|
+
// the `search` render says so rather than point the model at a read that cannot serve it.
|
|
70
|
+
...(h.object === "page" ? { readWith: { tool: "read_notion_page", args: { page: h.id } } } : {}),
|
|
68
71
|
}));
|
|
69
72
|
},
|
|
70
73
|
};
|
package/agent/channels/notion.ts
CHANGED
|
@@ -105,7 +105,10 @@ import {
|
|
|
105
105
|
seatCatchUp,
|
|
106
106
|
dispatchNativeWakeWithRetry,
|
|
107
107
|
readCappedBody,
|
|
108
|
+
OE_CHAT_NONMEMBER_ATTR,
|
|
109
|
+
rememberNonMemberAnswer,
|
|
108
110
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
111
|
+
import { withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
|
|
109
112
|
import {
|
|
110
113
|
forgetNotionQuestion,
|
|
111
114
|
rememberNotionQuestion,
|
|
@@ -305,7 +308,7 @@ type NotionState = NotionSessionState;
|
|
|
305
308
|
// Exported (not just internal to the route) so a test can drive the `wake` leg directly — the SAME
|
|
306
309
|
// reason liveGitHubSupervisorDeps/liveLinearSupervisorDeps are testable without a live webhook.
|
|
307
310
|
// `respond` is the OPE-1305 answer leg, and it is OPTIONAL for one reason: it is the only primitive
|
|
308
|
-
// that
|
|
311
|
+
// that answers a pending question by request id, so a caller that cannot supply it has no leg to run. Absent ⇒ the
|
|
309
312
|
// decision is not even attempted — no store read, and no admission call that could write a refusal
|
|
310
313
|
// row against a person whose answer could never have been delivered anyway. The live route supplies
|
|
311
314
|
// it; a test driving the send leg alone need not.
|
|
@@ -408,9 +411,10 @@ export function liveNotionSupervisorDeps(
|
|
|
408
411
|
return;
|
|
409
412
|
}
|
|
410
413
|
// ── THE ANSWER LEG (OPE-1305) ────────────────────────────────────────────────────────────
|
|
411
|
-
// 🔴 `respond`
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
+
// 🔴 `respond` ANSWERS BY REQUEST ID, after the room and floor check. The question is the
|
|
415
|
+
// session's OWN (OPE-2351: a Notion session has no children). ⚠️ A comment the check turns away
|
|
416
|
+
// still falls to the plain `send` below, which carries no `__eveInputText` override, so eve may
|
|
417
|
+
// take it as the answer (channels/notion-question-return.ts states the residual). So this
|
|
414
418
|
// branch is the whole return leg on the native route, and everything else here was already
|
|
415
419
|
// present. channels/notion-question-return.ts owns the decision and fails soft in one
|
|
416
420
|
// direction: no row, another discussion, a refused answerer ⇒ an ordinary send.
|
|
@@ -424,7 +428,7 @@ export function liveNotionSupervisorDeps(
|
|
|
424
428
|
if (respond !== undefined) {
|
|
425
429
|
const answer = await resolveNotionInboundAnswer({ org, bareToken: token, auth });
|
|
426
430
|
if (answer) {
|
|
427
|
-
const deliverAnswer = () => deliverNotionAnswer({ answerOrg: org, token,
|
|
431
|
+
const deliverAnswer = () => deliverNotionAnswer({ answerOrg: org, token, message, context, answer, respond, surfaceKey, auth });
|
|
428
432
|
if (timing ? await timing.measure("eveSend", deliverAnswer) : await deliverAnswer()) return;
|
|
429
433
|
}
|
|
430
434
|
}
|
|
@@ -476,9 +480,9 @@ function notionSendFrom(from: RouteHandlerArgs<NotionState>["from"]): SendFn {
|
|
|
476
480
|
}
|
|
477
481
|
|
|
478
482
|
/** The ANSWER delivery (OPE-1305) — `from(token).respond([{requestId, text}])`, the one primitive
|
|
479
|
-
* that
|
|
483
|
+
* that answers a pending question by request id. Structural, for the same reason {@link SendFn} is: eve exports no
|
|
480
484
|
* public type for it. */
|
|
481
|
-
type RespondFn = (input: { requestId: string; text: string }, opts: {
|
|
485
|
+
type RespondFn = (input: { requestId: string; text: string }, opts: { continuationToken: string; context?: readonly string[] }) => Promise<void>;
|
|
482
486
|
|
|
483
487
|
/** Adapt eve's `from(address).respond(responses, options)` to {@link RespondFn}. */
|
|
484
488
|
function notionRespondFrom(from: RouteHandlerArgs<NotionState>["from"]): RespondFn {
|
|
@@ -486,7 +490,12 @@ function notionRespondFrom(from: RouteHandlerArgs<NotionState>["from"]): Respond
|
|
|
486
490
|
await (from(opts.continuationToken) as unknown as { respond: (r: readonly { requestId: string; text: string }[], o: unknown) => Promise<unknown> }).respond(
|
|
487
491
|
[{ requestId: input.requestId, text: input.text }],
|
|
488
492
|
{
|
|
489
|
-
|
|
493
|
+
// 🔴 NO AUTH (OPE-2336, Tommy 2026-10-05: turns run as rooms). A comment that answers a held
|
|
494
|
+
// question steers the room's turn. It must not change WHO the turn runs as: eve sets the
|
|
495
|
+
// session's bag from any delivery that carries auth, inside the same turn id, with no principal
|
|
496
|
+
// check. With the key left out, the turn keeps the bag it opened under. The commenter's
|
|
497
|
+
// standing for the ANSWER was already judged by the admission floor. The KEY is left out,
|
|
498
|
+
// never set to `null`, which would clear the bag.
|
|
490
499
|
// The side-channel context rides the answer for the same reason it rides the send: a comment
|
|
491
500
|
// that happens to resolve a question must not lose the context every other wake carries.
|
|
492
501
|
...(opts.context && opts.context.length > 0 ? { context: [...opts.context] } : {}),
|
|
@@ -545,12 +554,13 @@ function timedNotionWebhookRoute(
|
|
|
545
554
|
async function deliverNotionAnswer(input: {
|
|
546
555
|
readonly answerOrg: string;
|
|
547
556
|
readonly token: string;
|
|
548
|
-
readonly auth: unknown;
|
|
549
557
|
readonly message: string;
|
|
550
558
|
readonly context: readonly string[];
|
|
551
559
|
readonly answer: NotionInboundAnswer;
|
|
552
560
|
readonly respond: RespondFn;
|
|
553
561
|
readonly surfaceKey: string;
|
|
562
|
+
/** The answering wake's own auth — read ONLY for its `oe_chat_nonmember` stamp (OPE-2348). */
|
|
563
|
+
readonly auth?: { readonly attributes?: Readonly<Record<string, unknown>> } | null;
|
|
554
564
|
}): Promise<boolean> {
|
|
555
565
|
const detail = {
|
|
556
566
|
requestId: input.answer.requestId,
|
|
@@ -558,7 +568,6 @@ async function deliverNotionAnswer(input: {
|
|
|
558
568
|
chars: input.message.length,
|
|
559
569
|
surfaceKey: input.surfaceKey,
|
|
560
570
|
route: "native",
|
|
561
|
-
...(input.answer.taskId !== undefined ? { taskId: input.answer.taskId } : {}),
|
|
562
571
|
...(input.answer.wakeId !== undefined ? { wakeId: input.answer.wakeId } : {}),
|
|
563
572
|
};
|
|
564
573
|
// The SAME partition every other Notion row takes (`notion.dispatched` above): the workspace id
|
|
@@ -566,9 +575,20 @@ async function deliverNotionAnswer(input: {
|
|
|
566
575
|
// detail instead, so an operator can still join these rows to the dispatch that carried them.
|
|
567
576
|
const [, workspace = "unknown", surface = "unknown"] = input.surfaceKey.split("/");
|
|
568
577
|
try {
|
|
578
|
+
// 🔴 A GUEST's ANSWER CARRIES THE GUEST's STAMP (OPE-2348) — the native twin of the unified answer
|
|
579
|
+
// road (core channels/unified.ts). The answer rides no auth, so the `oe_chat_nonmember` stamp that
|
|
580
|
+
// `stampNonMemberChatAuth` put on this comment's wake is recorded on the held-question row first,
|
|
581
|
+
// and the asking turn marks itself from it when eve resolves the question (core
|
|
582
|
+
// hooks/held-question.ts). FAIL CLOSED: a stamp that cannot be written refuses the answer, and the
|
|
583
|
+
// catch below falls through to the ordinary send, which carries the stamped bag itself.
|
|
584
|
+
const stamp = input.auth?.attributes?.[OE_CHAT_NONMEMBER_ATTR];
|
|
585
|
+
const nonMember = (Array.isArray(stamp) ? stamp[0] : stamp) === "true";
|
|
586
|
+
if (nonMember && !(await rememberNonMemberAnswer(getOperationalStore(), input.answerOrg, input.answer.requestId))) {
|
|
587
|
+
throw new Error("the answerer is a non-member and the stamp could not be recorded — an unmarked guest answer is never delivered");
|
|
588
|
+
}
|
|
569
589
|
await input.respond(
|
|
570
590
|
{ requestId: input.answer.requestId, text: input.message },
|
|
571
|
-
{
|
|
591
|
+
{ continuationToken: input.token, context: input.context },
|
|
572
592
|
);
|
|
573
593
|
} catch (err) {
|
|
574
594
|
const error = (err instanceof Error ? err.message : String(err)).slice(0, 300);
|
|
@@ -1063,7 +1083,9 @@ const notionWebhookRouteHandler = timedNotionWebhookRoute(
|
|
|
1063
1083
|
},
|
|
1064
1084
|
);
|
|
1065
1085
|
|
|
1066
|
-
|
|
1086
|
+
// OPE-2252 — `reply`/`no_reply` end the turn natively (`endsTurn`) and eve then emits no final message,
|
|
1087
|
+
// so the wrapper decides such a turn at `turn.completed` (core `channels/ends-turn-final.ts`).
|
|
1088
|
+
export default withEndsTurnFinalChannel(defineChannel<NotionState, { state: NotionState }>({
|
|
1067
1089
|
kindHint: "notion",
|
|
1068
1090
|
// 🔴 QUEUE, NEVER STEER (OPE-412, OPE-1732). eve's default is `steer`, and a steer delivery
|
|
1069
1091
|
// carrying a message CANCELS the turn in flight — rolling session history back to the last
|
|
@@ -1128,7 +1150,9 @@ export default defineChannel<NotionState, { state: NotionState }>({
|
|
|
1128
1150
|
//
|
|
1129
1151
|
// 🔴 AND IT REMEMBERS (OPE-1305), ONLY ON A POSTED QUESTION. The row keyed by this session's
|
|
1130
1152
|
// token is what turns the discussion's next comment into `respond([{requestId, text}])` in the
|
|
1131
|
-
// `wake` dep above — the
|
|
1153
|
+
// `wake` dep above — the delivery by request id of an admitted answer to the session's OWN question
|
|
1154
|
+
// (OPE-2351; not a fence, see channels/notion-question-return.ts). Its first use was a DELEGATED
|
|
1155
|
+
// WORKER's question, which a Notion session no longer has. eve
|
|
1132
1156
|
// proxies the child's request up through the PARENT's channel adapter, so the prompt reaches this
|
|
1133
1157
|
// handler and lands in the thread, but the answer only reaches the child through an
|
|
1134
1158
|
// `inputResponses` entry whose `requestId` sits in the parent's proxy map; a plain message falls
|
|
@@ -1172,7 +1196,7 @@ export default defineChannel<NotionState, { state: NotionState }>({
|
|
|
1172
1196
|
log(
|
|
1173
1197
|
remembered
|
|
1174
1198
|
? `question bridge: posted ${question.toolName} (${question.kind}) into discussion ${target.discussionId} — answerable there (${question.requestId})`
|
|
1175
|
-
: `question bridge: posted ${question.toolName} (${question.kind}) into discussion ${target.discussionId} — NOT answerable (no outstanding-question row)
|
|
1199
|
+
: `question bridge: posted ${question.toolName} (${question.kind}) into discussion ${target.discussionId} — NOT answerable by request id (no outstanding-question row)`,
|
|
1176
1200
|
);
|
|
1177
1201
|
},
|
|
1178
1202
|
// Deliver the supervisor's reply back into the SAME Notion discussion thread — OR audit its
|
|
@@ -1225,7 +1249,7 @@ export default defineChannel<NotionState, { state: NotionState }>({
|
|
|
1225
1249
|
});
|
|
1226
1250
|
},
|
|
1227
1251
|
},
|
|
1228
|
-
});
|
|
1252
|
+
}));
|
|
1229
1253
|
|
|
1230
1254
|
// Re-parse the handshake token purely to hand it to captureHandshakeSecret above (handleNotion
|
|
1231
1255
|
// Delivery already consumed it; this keeps that function's return type clean). Returns "" rather
|
package/agent/docs-generated.ts
CHANGED
|
@@ -10,6 +10,6 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
|
|
|
10
10
|
path: "docs/deploy/surfaces/notion.md",
|
|
11
11
|
title: "Notion",
|
|
12
12
|
summary: "Connect Open Everywhere to a Notion doc and it becomes an ambient teammate that lives in the page: it silently reads every comment anyone leaves, holds a standing per-doc brain, and steps in only where it clearly adds something — answers a…",
|
|
13
|
-
markdown: "# Notion\n\nConnect Open Everywhere to a Notion doc and it becomes an **ambient teammate that lives in the page**: it silently reads every comment anyone leaves, holds a standing per-doc brain, and steps in **only where it clearly adds something** — answers a question raised in a comment, resolves a decision a thread is circling, flags a factual problem — and stays invisibly (but audibly) silent otherwise. Name it in a comment and it engages; ignore it and it fades into the background, watching and remembering.\n\nThis is the Notion-specific setup runbook. For the concept of a *surface*, see [Surfaces](../../extending/surfaces.md); for how memory is scoped, see [Memory](../../capabilities/memory.md). Notion is comment/discussion-based rather than chat-based (like [Linear](./linear.md)), so it's out of scope for the chat [support matrix](./support-matrix.md).\n\n## How it works — an ambient-judged surface\n\nEvery comment on a connected doc fires a `comment.created` webhook to your instance. That event is a **signal only** (it carries the comment id, not the text), so Open Everywhere does a two-step fetch to retrieve the comment, then a **standing per-doc supervisor session** — the same ambient judge OE's Slack channel supervisor uses — decides whether to engage:\n\n- **It reads every comment.** Most need nothing from it, and the bar to reply is deliberately high (\"when you're unsure whether a message clears that bar, it doesn't\"). It replies only when it clearly adds something the people there couldn't easily get themselves.\n- **A structured @mention guarantees a reply** (the conversation-driver treatment). A **name-drop** — a person typing the agent's display name in a comment — is a *strong input to the judge* (\"addressed to you or names you\"), not a bypass: the wake stays judged.\n- **Silence is audited.** Every judged-silent turn writes a `notion.no_reply` audit row (reason included), so \"why didn't it reply?\" is always answerable — parity with Slack.\n- **On demand it can read the page's discussion** with the generic `read_channel` / `read_thread` verbs — the comment threads and inline discussions on the page, so it can catch up on an argument it wasn't tagged into rather than answering out of context. `read_thread` reads the one discussion the wake is in; `read_channel` reads every discussion on the page, each headed by the block it anchors to.\n- **The doc BODY is a different plane.** The page is the work object, the comment layer is Notion's chat surface, and page prose never rides the chat verbs. The supervisor session reads a body two ways: the domain declares the Notion **work credential**, which mounts `read_notion_page` on the session directly, or the session **delegates to a worker**, which holds `read_notion_page` and `read_notion_comments` on the chat credential. A domain with no work credential and no delegation route reads no page body — that is the ruling, not a gap.\n- **A page body read over the REST skill is taint-marked too.** The `notion-rest` skill's `page markdown`, `blocks list` and `comments list` print their answer inside the skill envelope, source `notion-rest`. The host's `bash` tool fences that span as untrusted content and marks the turn with kind `skill-declared`. No `taint.verdict` row records this mark: that row records only a divergence between the call site and the tier table, or an assumed tier, and `skill-declared` is always untrusted, so the two agree. When a delegated worker made the read, the seat adopts the mark at settlement, and the seat's `delegation.task_settled` row lists `skill-declared` in its `kinds`. So all three roads to a page body mark the turn: `read_notion_page` (kind `notion`), a lent grant tool (kind `connector`), and the REST skill (kind `skill-declared`). Titles, ids, errors and the output of `search`, `page current`, `page get`, `db query` and the raw `api` verb are not wrapped.\n- **A worker on the REST skill reads \"this page\" by name, not by guess.** The wake envelope names the page only as an opaque channel id, by design. So the host writes the page id to `current-page.json` beside the `notion-rest` skill for every session, and sets it only when the session's surface is a Notion page (`notion/<workspace>/<page>`). `node scripts/notion.mjs page current` prints the page's id, title and url, and `page markdown current` reads its body. On any other surface the file holds `null`, and both verbs print \"This conversation is not on a Notion page\" and exit non-zero. The host rewrites the file on every turn, so a unified window that moves off a Notion page never keeps the old id. Two instruction rules close the loop: the seat never puts a page id it inferred (from the channel id or a message id) into a worker brief, and for \"this page\" it tells the worker to read the current page; the worker uses `current` for the page the conversation is on even when the brief names an id, and uses an explicit id only for a different page the person named.\n- **Every read is fenced to the calling session's domain.** A session may read its own page, any page in its domain's `reach`, and — if the domain reaches the whole workspace — any page in that workspace. Anything else is **refused**, including a page whose domain cannot be determined at all: an unassigned page is not readable, it is a refusal. Every refusal writes a `read.refused` audit row naming the page and both domains. (Underneath that sits Notion's own share list, which the next paragraph is careful about.)\n- **Every read is dated.** The generic verbs date every line they return. `read_notion_page` states the read instant and that its text is the page's current content at that moment. `read_notion_comments` and the generic verbs state the same instant and age every comment against it (\"27 minutes before this read\"), and mark the whole comment block HISTORY — a comment's claim about status or approval is not the page's current state; `read_notion_page` is the tool that reads that. Without the contrast the agent can narrate an old comment as the present, the same failure a Linear soak run measured (see [Linear](./linear.md#reading-an-issue)).\n\n**Which domain a comment wakes is fenced on the signer too.** A `comment.created` delivery names a workspace, and nothing proves that name: Notion returns no workspace id on any object, so no lookup can confirm it. The page is different — Open Everywhere establishes it from the comment Notion returns under the verified credential, never from the payload. So the risk is a delivery that pairs an honest page with a **forged workspace**, which would otherwise reach whichever domain claims that workspace in a container `reach` entry. Before the session opens, OE asks the same question the routine plane asks: is the domain the claim reached a domain the **signing integration** serves? If not, the delivery is dropped and a `notion.scope_refused` audit row records the page, the claimed workspace, the domain and both credentials.\n\nTwo consequences worth knowing:\n\n- **A page listed in a domain's `reach` is not fenced this way**, and that is deliberate. Its domain comes from the page id, which is authenticated. A page you share with two integrations produces one delivery per subscription, and each one must still run under the integration that sent it.\n- **`webhooks.allowUnverifiedNotion` identifies no signer**, so with that dev hatch on, a page reached only through a container entry wakes nothing. Name a page outright in `reach` to exercise it locally.\n\n**The session partition's workspace comes from config, not from the delivery.** A delivery's `workspace_id` is unauthenticated and Notion returns no workspace id on any object, so the surface key composes the page with the workspace segment of the `reach` entry that matched. What that buys differs by leg, and the difference is worth knowing:\n\n- **A page named outright** matches by page id alone, so config and the payload can genuinely disagree — and config wins. A stale, mistyped or forged workspace beside such a page can no longer open a second partition beside the honest one and split that page's session and memory history.\n- **A page a container swept in** only matches when the payload's workspace equals the container's own text, so there the two agree by construction. A delivery naming a workspace no domain reaches matches nothing: its key belongs to no domain, gets no memory and no bundle, and the ambient gate refuses it. It does not join the domain under a second key — and Open Everywhere will not invent the domain's workspace for it, because that would hand a forged delivery that domain's brain.\n\nBoth legs rest on one config rule, refused at load: **a domain may reach at most one Notion workspace by container**. A container states no workspace for the pages it sweeps in, so two of them would leave \"which partition?\" to the payload. Name pages outright and a domain may still span workspaces — an exact entry carries its own.\n\nReplies post back into the **same discussion thread** (`POST /v1/comments`).\n\n> The agent improves a doc **by commenting on it**, and reads it to answer. It does **not** edit doc blocks — a write path (with per-action approval) is a deliberate future scope, not v1.\n\n### Naming the agent engages the page\n\nNotion lets nobody @mention an internal integration, so **naming** the agent in a comment is how it\nis addressed here (the brand phrase, `oe`, or whatever `OE_AGENT_NAME` / `OE_AGENT_SHORT_NAME` sets).\nThat name-drop also marks the **page** engaged: later plain comments on it — in any of its\ndiscussions — wake the agent with no second name-drop, because the invitation was the comment\nsomebody already wrote.\n\n- **It needs the page to be in a memory domain, and that is the floor.** A name-drop answers on a\n page one of your domains names outright in `reach` or admits by a container entry, whether or not that domain has\n `ambientSupervisor` on. A page the integration can merely *see*, in no domain at all, still does\n nothing. So this is one door — an addressed page can be addressed — not an open one.\n- **The grain is the page, not the discussion.** One standing session serves the whole page, so a\n stop asked for in one discussion is a stop for all of them.\n- **Asking it to stop works, and it is durable.** Say it in words and the agent calls `stand_down`,\n which stores the stop against the page. It then answers nothing there — including through the\n ambient supervisor below — until somebody names it again. There is no timeout.\n- **A page nobody ever named it on is untouched** by this. It wakes only through the ambient\n supervisor below.\n- **An author your `externalPolicy` excludes cannot engage a page**, and cannot lift a stop by\n naming the agent — their comment is suppressed before the mark, so nothing is written.\n\n### Opt-in is per **domain** (fail-closed off)\n\nThe whole ambient path is **off by default**. It only runs when the Notion doc's memory **domain** has `ambientSupervisor: true` (the same domain flag that turns on Slack's ambient supervisor). Compose a domain, flip ambient once, and every connected doc in it goes ambient — one operator action, then it \"just works\", while staying explicit. Until a domain opts in, a doc in that domain does nothing **unless somebody names the agent on it or has already done so** — the addressed and engaged paths above are the two exceptions, and every other comment is an explicit, audited no-op rather than a silent drop. A doc in **no** domain does nothing at all, name-drop included. A domain can compose docs **across platforms** (Slack channels + GitHub repos + Linear teams + Notion docs share one flag and one silo), and how eagerly the supervisor steps in is tunable per domain with `engagementTier` (Notion defaults `neutral` — see the [configuration reference](../configuration-reference.md#memory-domain--silo)).\n\n## Structural changes can trigger routines — comments cannot\n\nOne Notion webhook subscription now feeds **two** things, and they are split cleanly by event type:\n\n- **Comments are conversation.** `comment.created` / `comment.updated` / `comment.deleted` go to the ambient supervisor above. They can **never** trigger a routine — a comment is a thread the agent participates in, not a job.\n- **Structural changes are work.** `page.*`, `database.*` and `data_source.*` — a page created, moved, deleted, its properties updated, a schema changed — are what a [routine](../../capabilities/proactivity-and-routines.md) can subscribe to with an `event` trigger on source `notion`. The ambient supervisor never sees them.\n\nNo single delivery does both. Nothing new to configure: the subscription and its signing secret are the ones you set up in Step 2.\n\nA subscription's filter is a [jq expression](../../capabilities/proactivity-and-routines.md#event-filters-are-jq), and it **must narrow its scope** — reference `.fields.parent` (the containing database or page), `.fields.entity` (the page itself), or a `.scope.<kind>` level. Naming only the workspace is refused: subscribing to *every* change in a whole Notion workspace has to be spelled out deliberately, not defaulted into. The fields this source flattens out are `event`, `action`, `entity_type`, `workspace`, `parent` and `entity` — e.g. `.fields.parent == \"<database-id>\" and (.fields.action | IN(\"properties_updated\"))`. Notion's scope path is `workspace → teamspace | database/page → page → block`; note that Notion's webhook envelope carries exactly **one** parent, so a delivery proves either the teamspace *or* the containing database, never both.\n\n### 🔴 What authorizes a Notion trigger — and what does not\n\nA routine only fires on a Notion event if **the routine's domain is the domain you put the affected Notion surface in** (the same `domains` registry that turns the ambient supervisor on). The innermost surface the registry knows about decides: if you have placed a page in `payments`, a routine in `security` cannot subscribe to it, even if the *database* that page lives in is in `security`. A page in no domain triggers nothing, anywhere.\n\n**A second check runs first, and it is about who signed the delivery.** A webhook signature proves which subscription's `verification_token` signed the bytes. It does **not** prove that Notion wrote them: whoever holds a token can sign a body naming any workspace, database or page they choose. So before a delivery reaches the routine plane, Open Everywhere reads the domain that owns the named surface and asks whether the **signing integration serves that domain**. A body signed with `payments`'s token that names a `security` page is dropped, and the reason is logged.\n\nThat check is only as strong as your subscription layout, and the rule is exact: **a domain is protected from another domain's forged deliveries only when it runs its own subscription with its own `inboundSecret`.** Two integrations that share one inbound secret sign identically, so the signature cannot tell them apart and the check accepts both. The same rule and the same limit govern the comment plane's fence [above](#how-it-works--an-ambient-judged-surface). If you hand a domain's `verification_token` to that domain's operators, give that domain its own `inboundSecret` at the same time ([below](#step-4--one-integration-per-domain-optional)).\n\n**Be clear about what that check is standing on, and it depends on how you configured the credential.** A Notion credential is an internal-integration secret whose visibility is a share list maintained inside Notion, page by page.\n\n- **On the default setup** — one integration for the whole deployment — that credential is **identical for every domain and cannot express anything narrower than the workspace**, so it is *not* the domain boundary and OE does not present it as one. The per-domain boundary is your `domains` config, and the per-page boundary is Notion's own sharing. Neither substitutes for the other.\n- **When you bind a domain to its own integration** ([below](#step-4--one-integration-per-domain-optional)), that domain's calls run under a credential Notion itself scopes to that integration's share list. The boundary is then **vendor-enforced** for the pages you did not share elsewhere — the strongest isolation any OE surface offers.\n\nEither way the same instruction holds: if you need a Notion page not to reach a given domain, put it in a different domain **and** do not share it with that domain's integration.\n\nThe same reasoning is why `read_notion_page` / `read_notion_comments` carry their own domain fence rather than leaning on the credential. Notion will happily serve any page the integration was shared into, whichever domain is asking, so the domain check has to be ours — and it is applied to the page id the agent asks for, not just to the page it was woken on. The `block` drill-in on `read_notion_comments` is checked the same way: Open Everywhere asks Notion which page owns the block and fences that, and a block it cannot place is refused.\n\n`read_notion_page` also FALLS BACK to that same block-resolution walk on its own `page` argument: the model occasionally hands it a discussion/block id instead of the page it lives on, and rather than refusing outright, OE asks Notion which page owns it and fences **that** page through the identical domain check — never a looser one. Resolution only ever produces an id that still has to clear the same allowlist a directly-passed page id would, so it cannot become a bypass. If the id can't be placed at all, or the page it resolves to is itself out of scope, the read stays refused — and the refusal says so, naming the block-id possibility so the caller can pass the parent page id directly next time.\n\n## How the silo model maps\n\nOpen Everywhere's memory is [opt-in domains and silos](../../capabilities/memory.md), not an\nautomatic per-workspace tier stack. A Notion comment lives on a **page** in a workspace,\ninside a **discussion**. The page is the persistent container that hosts many discussions\nover time, so:\n\n| Open Everywhere concept | Notion thing |\n| --- | --- |\n| `org` (tenant) | your deployment (`deploymentIdentity.org` in `agent/identity/surfaces.ts`, one org per deploy today) |\n| `workspace` (identity key) | the Notion **workspace** (`workspace_id`) |\n| `surface` (partition key) | the **page** the comment lives on (`page_id`) |\n| invocation + post-back anchor | the **discussion** thread (`discussion_id`) |\n| `mode` | `channel` (Notion has no 1:1 DM analog) |\n| `visibility` | `private`, fail-closed (see below) |\n\n**Surface = page, not discussion.** Every discussion on a page shares one memory silo, so the doc brain carries context across all of a page's threads instead of fragmenting per thread.\n\n### The \"doc brain\" needs a workspace vouch\n\nVisibility **fails closed**. Notion's webhook carries no reliable \"this page is a broadly-shared internal space\" signal, so `bindNotion` pins `visibility=private` and leaves trust undefined → the session falls closed to `external`, and the **shared page silo is gated off** unless the operator explicitly **vouches the workspace** (`OE_TRUST=internal`, or a per-workspace vouch in `surfaces.ts`). Ambient does **not** relax this — reading more comments never grants more trust.\n\nConsequence: unvouched, the agent still judges and replies, but **without a shared doc-brain silo**. The full \"remembers every prior discussion on this doc\" experience lights up only once you vouch the workspace — one deliberate operator action.\n\n## Prerequisites\n\n- **Admin** on the target Notion workspace (to create an internal integration and subscribe a webhook).\n- A reachable Open Everywhere instance (public HTTPS, or local with a tunnel). The Notion webhook route is **`/eve/v1/notion`**.\n\n## Step 1 — Create an internal integration\n\n1. Go to **notion.so/my-integrations → New integration** (internal). Name it (e.g. `Open Everywhere`). Under **Capabilities**, tick exactly three: **Read content**, **Read comments** and **Insert comments**. The comment surface uses all three — it reads the page (`Read content`), reads the discussion (`Read comments`), and posts and edits its own reply (`Insert comments`, which also covers `PATCH /v1/comments/{id}`). Leave **Update content** and **Insert content** off: Open Everywhere never writes a page block. Leave the user-information options off too: the only user it reads is its own bot (`GET /v1/users/me`), which needs no user capability. An integration that omits **Read content** still replies, but every page read fails — the mistake looks like a broken read tool, not a missing capability.\n2. Copy the **Internal Integration Secret**. Load it into the credential store: `oe secret set db:notion:channel-key` (value on stdin — see [`oe secret`](../setup-runbook.md#oe-secret--the-operator-door-day-0-no-surface-required)). This backs every REST call the comment surface makes: retrieving comments, listing a discussion, reading a page, posting replies. There is no fallback — an unset `db:notion:channel-key` fails the comment surface closed.\n3. **Share the docs (or a workspace parent page) with the integration** (a page's `•••` → *Connections* → your integration). An internal integration only sees pages explicitly shared with it — sharing a page is itself the connect action.\n\n> **Migrating from an env var?** Before OPE-408, this integration secret was read from `NOTION_CHANNEL_API_KEY`. That env var is no longer read by anything — the comment surface now resolves only `db:notion:channel-key`. If your deployment still carries the old env var, this is a **silent break**: the webhook still `200`s, but the reply never posts, and the session log carries a single line — \"reply couldn't post to Notion — no value stored under db:notion:channel-key\". The fix is one command: `oe secret set db:notion:channel-key` with the same value the env var held, then you can delete the env var.\n\n## Step 2 — Subscribe the webhook + complete the handshake\n\n1. In the integration's **Webhooks** tab, add a subscription with the URL:\n\n ```\n https://<your-deploy-url>/eve/v1/notion\n ```\n\n Subscribe to the **`comment.created`** event.\n2. Notion sends a one-time **verification** POST (`{ \"verification_token\": \"…\" }`). That token is the **permanent HMAC signing key** for every future delivery, so Open Everywhere **never prints it** — a signing key in a log is forgeable by anyone with log access for as long as logs are retained. Instead it stores the value encrypted and logs a redacted line with a fingerprint and a **one-shot, 15-minute link**:\n\n ```\n [open-everywhere:notion] handshake secret for Notion webhook (verification_token) received and\n STORED — NOT logged (sha256 fingerprint 1a2b3c4d; confirm your env var with:\n printf %s \"$SECRET\" | sha256sum | cut -c1-8). Read it ONCE here, within 15 minutes — the link is\n single-use and whoever opens it first gets the secret, so treat it as the secret itself:\n https://<your-deploy-url>/connect/v1/reveal?t=…\n ```\n\n Open the link, copy the value, then do **both**: paste it into Notion's verify form to confirm the endpoint, **and** load it into the credential store with `oe secret set NOTION_WEBHOOK_SECRET` (value on stdin). A `NOTION_WEBHOOK_SECRET` **environment variable** of the same name still works and is the migration on-ramp; the stored value wins when both exist.\n\n > **The link is bearer-only.** It is bounded by *single-use* and a *15-minute TTL* — not by identity, because at handshake time there is no one to bind it to. Treat the log line as sensitive until you have redeemed it, and redeem it promptly.\n\n Confirm afterwards that what you pasted is what Notion sent, without exposing it:\n\n ```bash\n printf %s \"$NOTION_WEBHOOK_SECRET\" | sha256sum | cut -c1-8 # must equal the logged fingerprint\n ```\n\n **Requirements and recovery.** The reveal page is armed only when the credential vault is armed (`OE_CREDENTIAL_ENCRYPTION_KEY`) and a public base URL (`OE_PUBLIC_BASE_URL`, else the Vercel deployment URL) is set — its own HMAC secret (`OE_INTAKE_PAGE_SIGNING_KEY`) mints itself on first use and needs no operator step (see the [configuration reference](../configuration-reference.md)). Without those two, the token is still captured and still not logged; the log tells you to configure them. If the link expires, is used by someone else, or you lose the value: **delete and recreate the Notion subscription** so a fresh handshake fires. There is no re-reveal, by design — a re-reveal path would make every stored key a standing disclosure oracle, and in the audit log an authorised re-read is indistinguishable from an exfiltration.\n\n### Before you resume a paused subscription\n\nA verified delivery is acknowledged `200` only after its projected, content-minimal event has been\ncommitted to the operational store. Processing starts immediately, while the mounted\n`notion-ingress` schedule retries due work every minute after a process exit or failed self-call.\nProduction therefore needs a **persistent operational store**, `OE_CREDENTIAL_ENCRYPTION_KEY`, and\na resolvable deployment URL (`OE_PUBLIC_BASE_URL`, or Vercel's injected deployment URL). The\ninternal drain's HMAC key is minted into the existing credential vault; do not create or distribute\nanother secret. On Vercel, a fresh production deploy registers the schedule; run\n`npm run deploy:verify` after deployment so its cron-registration check can compare the deployed\ncron set with the build. On a self-host, run the compiled app through `eve start`, which starts the\nmounted schedule; a bespoke HTTP-only route host does not run schedules.\n\nIf you paused the Notion subscription for an upgrade, keep it paused until the new deployment boots,\nthe operational store is persistent and reachable, the self-call URL resolves to that deployment,\nand the cron-registration check passes. Then resume the existing subscription; do not recreate it\nor rotate its signing secret merely to resume delivery.\n\nDelivery is **at least once**. Pending payloads have no time-based expiry, so an extended outage\ndoes not silently delete accepted work. Completion erases the payload and retains only a dedupe\ntombstone for 24 hours. A crash after Eve accepted a comment but before completion can produce a\nduplicate turn after the five-minute lease expires; the comment-level guard suppresses ordinary\nredelivery for ten minutes. A refusal before dispatch removes the pending row, so a later vendor\nredelivery can be reconsidered after configuration or credentials are repaired.\n\n## Step 3 — Configure the environment\n\nBoth secrets are already handled: the integration secret lives in the credential store (Step 1.2: `oe secret set db:notion:channel-key`), and the handshake secret is captured and stored automatically (Step 2.2). Nothing here is a secret — everything below is optional:\n\n```bash\nNOTION_BOT_USER_ID=… # the ORG-DEFAULT integration's own user id — self-reply loop guard.\n # Auto-resolved via GET /v1/users/me; set it to skip that call (or in\n # tests). It pins the `channel-key` integration ONLY: a domain that binds\n # its own integration always resolves that one live, because two Notion\n # integrations never share a bot user.\nNOTION_BOT_NAME=… # the integration's display name — the name-drop judge input. Auto-resolved too.\nNOTION_VERSION=2022-06-28 # Notion API version header (default shown)\nOE_TRUST=internal # VOUCH the workspace so the shared doc-brain silo lights up (see above)\n# DEV ONLY, and it is a SETTING not a variable: turn on `webhooks.allowUnverifiedNotion`\n# in agent/settings/settings.ts to accept unsigned deliveries for local smoke tests.\n```\n\n**Then turn ambient on for the doc's domain** (`ambientSupervisor: true` on the memory domain — see [config-as-code](../../how-it-works/config-as-code.md) / [Memory](../../capabilities/memory.md)). Without it, the surface stays silent.\n\n## Step 4 (optional) — Notion as a **work surface**\n\nSteps 1–3 wire Notion as a **chat surface**: the agent is woken by a comment, reads that page, and replies there. It cannot read a Notion page from a Slack conversation, and a worker it delegates to cannot either. That is deliberate — capability comes from what the **domain** holds, not from the surface the conversation happens on.\n\nTo let a domain **work** on Notion, declare its Notion work credential:\n\n```ts\ndefineDomain({\n name: \"payments\",\n reach: [\"slack/T…/C…\", \"notion/<workspace>/<page>\"],\n credentials: [\n // The GRANT. `credential: \"team-key\"` names Notion's WORK role; `use` names the\n // credential id this domain's Notion work calls run under.\n { connector: \"notion\", credential: \"team-key\", use: \"payments-notion-key\" },\n ],\n});\n```\n\nWhat the declaration turns on, for **every** surface in that domain:\n\n| tool | what it does |\n|---|---|\n| `notion_search` | Find pages and databases by title and text. Returns ids + titles, never page content. Mounted **only** under this grant. |\n| `read_notion_page` | Read a page the domain owns — now from a Slack, GitHub, Linear, Teams or Discord session too, not only from a Notion wake. |\n\nFour rules govern it:\n\n- **The work credential needs ONE capability: Read content.** Both tools only read — `notion_search` calls `POST /v1/search`, `read_notion_page` calls `GET /v1/pages` and `GET /v1/blocks`. Tick no comment capability and no write capability on a work integration. A work integration that carries either is wider than this grant can use.\n- **Absence is a refusal, not a default.** A domain that declares nothing for the work role holds no work grant, so no Notion work tool mounts on any of its sessions. This is the opposite of the chat/plumbing roles, where \"no binding\" means \"use the deployment default\".\n- **Use a separate integration.** The chat credential (`db:notion:channel-key`) should be shared with the comment pages only. The work credential should be shared with the page trees the agent may build from. A chat integration that can also write your whole workspace is the hole this split closes — Open Everywhere ships the seam, your organisation scopes the keys.\n- **Search's reach is the credential's reach.** Open Everywhere does not filter Notion search results. Notion enforces the boundary itself (an internal integration sees only the trees a human shared with it), and a filter on top would read as a boundary while being none. Narrow the search by sharing a narrower integration. `read_notion_page` is separately fenced to pages the caller's own domain owns, so finding a page is not the same as being able to read it.\n- **A refused work-plane read is audited as a Notion refusal, not a Slack (or GitHub, or Linear, or Teams) one.** The `read.refused` row's `platform` column always names the read's subject — `\"notion\"` — because the row is about a refused Notion page, chat plane or work plane alike. On the work plane the caller is a different platform's surface, so its `workspace`/`surface` columns would show that platform's ids under a `platform: \"notion\"` row, which reads as a bug. They are stamped `\"unknown\"` instead; the caller's real identity is never lost, because `detail.callerSurfaceKey` always carries the whole `platform/workspace/surface` string.\n\nWork tools are the **worker's**, not the seat's: a conversational seat delegates, and the delegated worker mounts them. See [connectors and providers](../../capabilities/connectors-and-providers.md#chat-surface-vs-work-surface).\n\n**Identity binds automatically.** You do not set workspace / surface / mode / visibility by hand. The channel takes the **page**, the **discussion** and the **commenting actor** from the comment Notion returns under the verified credential, takes the **workspace** from the delivery body (the vendor exposes no other source, so the fence above stands in for a proof), and `bindNotion` derives the rest.\n\n## Step 4 — One integration per domain (optional)\n\nBy default one Notion integration serves every domain. That is fine for most deployments, and it is\nthe only setup the steps above need. It also means the credential is **identical for every domain**,\nso the domain boundary is OE's config alone.\n\nNotion supports **many** integrations, and each one only sees the pages a human shared with it. Bind\na domain to its own integration and Notion itself enforces the boundary for that domain's pages.\n\n**1. Create a second integration** (Step 1), share **only** that domain's pages with it, and store\nits secret under its own name:\n\n```bash\noe secret set db:notion:payments-channel-key # value on stdin\n```\n\n**2. Declare it as a credential** on the notion catalogue in `agent/access/connector-credentials.ts`, beside the\ndefault `channel-key`:\n\n```ts\n{ id: \"payments-channel-key\", type: \"api-key\", secretRef: \"db:notion:payments-channel-key\", broker: \"db\", intake: false },\n```\n\n**3. Subscribe its own webhook** to the SAME URL (`/eve/v1/notion`) and complete the handshake\n(Step 2). Each integration issues its **own** `verification_token`, so store this one under its own\nname too:\n\n```bash\noe secret set NOTION_WEBHOOK_SECRET_PAYMENTS # value on stdin\n```\n\n**4. Bind the domain** in your `domains` config:\n\n```ts\ndefineDomain({\n name: \"payments\",\n reach: [\"notion/<workspace>/<page>\"],\n ambientSupervisor: true,\n credentials: [\n { connector: \"notion\", credential: \"channel-key\", use: \"payments-channel-key\", inboundSecret: \"NOTION_WEBHOOK_SECRET_PAYMENTS\" },\n ],\n}),\n```\n\n**What then happens.** Reads and posts an **agent-initiated** turn makes for a page in `payments` —\nno delivery woke it, e.g. a routine's proactive post — run under `payments-channel-key`. Every other\ndomain keeps using `channel-key`. A **delivery-woken** session's `read_notion_page` /\n`read_notion_comments` calls follow the rule two paragraphs down instead: the credential that\nverified the delivery, not the domain's binding. The `memory` role is unaffected either way — see\nthe note below.\n\n**An inbound delivery is decided by its signature, not by the page it names.** Every configured\nintegration's secret is tried, and the one that verifies identifies the sender — so both\nsubscriptions work on one endpoint with no routing table. That verifying integration then serves\nthe whole delivery: the two-step fetch, and the reply, which posts under the same credential that\nfetched. Nothing in the payload steers this, deliberately — a delivery's page id is unauthenticated,\nso if the page's binding outranked the signature, anyone holding one integration's\n`verification_token` could name another domain's page and have OE act with that domain's key.\n\n**The page the session lands on comes from the same place.** OE does not read it off the payload\neither. It reads the comment under the verifying integration's own key and takes the page from what\nNotion answers — the comment's page parent, or, for an inline comment, the page that owns the\ncommented block. That page decides the session's domain. `read_notion_page` / `read_notion_comments`,\nthough, run under the **verifying credential itself** — the one that fetched and will reply — never a\nfresh lookup off that domain's binding. One rule for the whole delivery: the credential that verified\nserves the fetch, those two read tools, and the reply alike. A delivery whose page will not resolve\nthat way is acknowledged and dropped; the payload's page id is never the fallback.\n\n**The `memory` role does not follow that rule.** A domain's Notion memory silo (its `memory`\ncredential, distinct from `channel-key` — see [Memory backings bind separately](#step-4--one-integration-per-domain-optional)\nbelow) still resolves off the **domain binding**, whichever integration verified the delivery. So a\ndouble-shared page's session reads its doc body and comments under the narrower, verifying key, but\nstill draws its shared brain — if any — under the domain's own memory integration.\n\nIn the configuration this step describes, the two answers are the same one: unshare the payments\npages from the default integration and the default subscription never sees them. They differ only\nwhile a page is shared with both integrations — and then each subscription's delivery correctly runs\nunder its own integration, which is what keeps both working.\n\n### The rules worth knowing before you rely on it\n\n- **A domain with no binding uses the org default.** That is the fallback, not a misconfiguration.\n You never have to bind every domain to bind one.\n- **A binding is never downgraded.** If `use` names a credential the provider does not declare, the\n deployment **refuses to boot** and names the domain. If the credential is declared but its secret\n is unset, that domain's Notion calls fail closed — they do **not** retry under the org key. The\n self-reply loop guard treats such an integration as unable to post **from this deployment**, so it\n does **not** make the rest of the deployment deaf; an integration whose key WORKS but whose\n `GET /v1/users/me` fails does — see the loop-guard bullet under [Gotchas](#gotchas).\n- **The vendor fence is only as good as your share lists.** Binding `payments` changes which key OE\n calls with; it cannot un-share a page. If the page is also shared with the default integration,\n that key can still read it — and the default subscription will also deliver that page's comments,\n which OE then serves under the default key, because that is the integration that proved it sent\n them. A comment on it wakes a session that surfaces on that page, so the session sits in the\n `payments` domain — but its `read_notion_page` / `read_notion_comments` calls run under the\n **default** key, because that is the credential that verified this particular delivery, not the\n domain's own `payments-channel-key`. So the cost of a double-shared page, for those two tools,\n stays what Notion's own share list says the default integration can see. The domain's `memory`\n silo is unaffected by which integration verified the delivery — it still pulls under whatever\n credential the domain's own `memory` role names (a separate binding from `channel-key`), so a\n double-shared page narrows the read tools' reach but not the memory silo's. Unshare it anyway:\n while it stays shared, the weaker default key is a second door onto that one page.\n- **`omit inboundSecret`** when several domains sit behind ONE subscription — they then share the\n deployment's default signing secret. Name your own the moment you create a second subscription.\n- **Each integration has its own bot user**, so the self-reply loop guard resolves and caches one\n identity per integration. 🔴 **`NOTION_BOT_USER_ID` pins the ORG-DEFAULT integration only, and that\n is deliberate.** Two Notion integrations never share a bot user, so one pinned id cannot stand for\n both; OE resolves every other integration live and the variable has no effect on them. The guard\n compares a comment against **every**\n configured integration's bot id, so two integrations that can both see one page do not answer each\n other: a reply one posts is recognised as the deployment's own words on the other's subscription.\n The cost is one `GET /v1/users/me` per integration, on the first delivery each process handles.\n- 🔴 **One integration whose bot id will not resolve makes the deployment deaf to every Notion\n comment Notion does not mark as person-authored — addressed ones included.** The guard fails\n closed: with an id missing it cannot tell that integration's own reply from a person's, because\n Notion omits `type` on a comment's author. It therefore drops every comment Notion does not\n explicitly mark as person-authored, until the id resolves. 🔴 **That is not only ambient chatter.**\n The check runs before the addressed arms, so during such an episode a name-drop that asks the agent\n directly, and an `oe link <code>` identity claim, are dropped too. The failure is\n cached for the life of the process, so a credential you fix takes effect on the next deploy or\n restart. **Check every integration's token after you add one** — an integration listed with a\n signing secret but no working token is the shape that trips this.\n- **Memory backings bind separately, on their own role.** The `notion` memory projection has its own\n credential role, `memory`, distinct from `channel-key`. Override it the same way:\n `credentials: [{ connector: \"notion\", credential: \"memory\", use: \"payments-memory-key\" }]`. A\n domain may bind one role, both, or neither — an operator often shares a domain's BRAIN pages with\n one integration and its WORK pages with another. Unbound domains read the brain under the org\n default (`db:notion:memory`), as before.\n- **A shared silo reads under EACH lister's own credential in a session.** A silo two domains both\n list is read, in a session, under the credential of whichever domain's `memory` list named it —\n so a bound domain pulls the shared subtree under its OWN integration. Share the silo's pages with\n every bound lister's integration, or its brain reads empty for that domain. Only the silo-first\n readers (the control plane, the tidy schedule, the purge worker) fall back to the org default for\n a silo with two owners.\n\n## Gotchas\n\n- **Ambient is off until a domain opts in.** A connected doc does nothing until its memory domain sets `ambientSupervisor: true`. If comments get no response, you no longer have to guess: the first delivery from a page the supervisor was never asked about writes a **`notion.ambient_skipped`** audit row naming the surface key, the reason (`no-domain` — nothing routes this page — versus `domain-refused` — a domain does route it but hasn't opted in) and the exact edit to make. Query it with `GET /control/v1/audit?since=<iso>&actions=notion.ambient_skipped`.\n- **The audit trail says whether a page is engaged.** Each dispatched comment writes a **`notion.dispatched`** row whose `detail.addressing` reads `direct` (somebody named the agent), `thread-follow` (a plain comment on a page an earlier name-drop engaged), or `channel-watch` (a plain comment on a page nobody has named the agent on). Query it with `GET /control/v1/audit?since=<iso>&actions=notion.dispatched`. 🔴 **`thread-follow` reports the engagement registry's state, not which door admitted the delivery.** The registry is read for every plain comment, before and independently of the ambient opt-in — so on a domain with `ambientSupervisor: true`, plain chatter on an engaged page reads `thread-follow` too, while the ambient flag alone would have admitted it. The row separates the engaged path from the ambient one only where the domain has ambient **off**. The same row carries **`detail.via`** — how your config holds the page: `roster` (a domain lists the page id under `reach`), `container` (a domain's container `reach` entry reaches it, so the page needed no entry of its own), or `none` (no domain holds it). Read it beside `addressing`: a `direct` wake with `via: container` means a claim admitted the name-drop on a page nobody enumerated, which is a wider door than a page you listed. 🔴 **It names the KIND of claim, never its breadth.** A container `reach` entry is either a whole workspace (`notion/<ws>`) or a same-level page glob (`notion/<ws>/<prefix>*`), and both read `container` — check the domain's own entry to see how far the claim reaches.\n- **The doc brain needs a workspace vouch.** Unvouched (the default), the agent judges + replies but has no shared page-memory silo — see [the doc-brain section](#the-doc-brain-needs-a-workspace-vouch). Set `OE_TRUST=internal` (or vouch the workspace) to unlock it.\n- **Self-reply loop guard (hardened), in two halves.** The agent reads its *own* replies (each reply fires a fresh `comment.created`), so both halves have to hold.\n - *Whose words.* It drops **its own** replies by comparing `created_by.id` against the bot user id of **every configured integration**, not only the one that verified the delivery — so two integrations that can both see one page do not answer each other. It **fails closed on every unreadable identity**: the verifying integration's own id unresolved, a comment with no `created_by.id`, and — on an author Notion does not mark `person` — a *sibling* integration's id unresolved. An unreadable identity drops the comment rather than risk looping on its own replies. If nothing ever dispatches, confirm the value stored under `db:notion:channel-key` is valid (so `GET /v1/users/me` can resolve the bot id) or set `NOTION_BOT_USER_ID` — **and check every other integration's stored key too**, because a sibling whose key works but whose `GET /v1/users/me` fails silences the whole deployment until it resolves. A sibling **this deployment holds no usable key for** cannot post from here, so it is skipped rather than fenced against — the residual being a token you deleted here that another environment still holds, whose replies then read as a person's. 🔴 **A FOREIGN bot — one that is not ours — is still dropped only when Notion supplies `created_by.type`, which it usually omits on a comment.** That limit is unchanged.\n - *How many times.* **One comment id normally wakes one turn**, with a durable ten-minute dispatch guard shared across instances. The webhook is acknowledged only after durable acceptance, and pending work is retried by the minute recovery schedule. The contract is at least once: a crash after Eve accepts the turn but before the guard and inbox completion are recorded may cause a duplicate after the five-minute lease expires. This prefers a possible duplicate over silently stranding an accepted comment.\n- **Identity-linking a Notion account needs the workspace vouched.** Notion has no @mention for an integration, so you claim a link code by **name-dropping** the agent — a comment whose content is `oe link ABC12345` (or `<your bot name> link ABC12345`). That is recognised only on an **addressed** (name-drop) comment, and — like the doc brain — only completes when the commenting account resolves **internal**: vouch the workspace (or set `OE_TRUST=internal`). Unvouched, the claim is refused (the author reads `external`), the same fail-closed posture that gates shared memory. The short/full names come from `OE_AGENT_SHORT_NAME` / `OE_AGENT_NAME` (default `oe` / `open everywhere`), the integration's own resolved display name, or the `identity.name` setting — the canonical name the agent introduces itself by.\n- **Silence is a first-class outcome.** Most comments correctly get no reply. Every silent turn is a `notion.no_reply` audit row — the surface is working, not broken. The engagement bar ships deliberately silence-biased; tune it by watching the live `no_reply` rate (no code change). A **different** quiet outcome is a `turn.supervisor_prose_dropped` row on the notion partition: the agent wrote an answer as its turn's closing text and never called `reply`, so nothing was posted. The `reply` tool is the only way words reach a discussion — closing text is never a delivery channel. That row is a defect signal, not a tuning signal — read the session transcript it names.\n- **Signing-secret verification is fail-closed.** Every real delivery is HMAC-verified against `NOTION_WEBHOOK_SECRET` (the stored secret, else the env var of that name). Unset or wrong → deliveries are rejected (401) and the agent stays silent. With several integrations configured, OE tries each one's secret and the one that verifies identifies the sender; a delivery no configured integration can verify is rejected. Running locally? Expose your instance with a tunnel and use that HTTPS URL.\n- **`webhooks.allowUnverifiedNotion` never fires a routine.** The dev hatch lets an *unsigned* delivery reach the ambient supervisor so you can smoke-test a laptop; it deliberately does **not** admit that delivery into the routine-trigger plane. An acceptance whose only proof is possession of the URL must not be able to start member-authored work. It also identifies **no signer**, so a page a domain reaches only through a container `reach` entry is refused under the hatch — list that page in the domain's `reach` to exercise it locally.\n- **A delivery can be refused for naming the wrong workspace.** If one domain's pages go quiet while another domain's work fine, read `GET /control/v1/audit?since=<iso>&actions=notion.scope_refused`. Each row names the page, the claimed workspace, the domain the claim reached and both credentials. The usual honest cause is a subscription whose integration does not serve the domain that claims the workspace: either bind that domain to the integration that actually delivers, or give the domain its own subscription.\n- **No ticket-action verbs (`set_state`/`assign`/`add_label`).** Unlike Linear/GitHub, a Notion\n page has no typed status/label/assignee schema OE can map to those verbs generically (a\n database's Status property shape is per-database, not a platform convention) — see\n [Source control § Ticket actions](../../capabilities/source-control.md#ticket-actions-set_state--assign--add_label).\n Called against a Notion ref, all three refuse cleanly (\"not supported on notion\") rather than\n guess at a mapping. A future Notion-database-aware mapping is possible but not built.\n- **No `create_ticket` either**, for the same reason: filing a \"ticket\" into Notion means creating\n a database row, and which property is the title (and which is status) is per-database, not a\n platform convention. Notion registers no create adapter, so `create_ticket` refuses on it\n **naming the surfaces that do work** (GitHub, Linear) instead of inventing a row shape — see\n [Source control § Filing new work](../../capabilities/source-control.md#filing-new-work-create_ticket).\n- **Accountability is OE-audited-only.** Every turn (dispatch, reply, and judged silence) is in OE's append-only audit log. Notion's own audit log is Enterprise-admin-only and not queryable by an integration, so OE's log is the sole record. This is the accepted interim tier until Notion's waitlisted **External Agents API** (a first-class, Notion-audited agent participant with native HITL) lands.\n\n## Status\n\nBuilt, unit-tested, and driven by the `J-NOT` end-to-end scenario in `packages/core/scripts/e2e/scenarios.ts` (`suite: \"core\"`, non-optional, deterministic — `judged: false` — gated on the `notionAmbient` fixture), with `J-ENGAGED-notion` covering the engaged-follow-up seam. `J-NOT` drives the on-topic anchor reply, reply-targeting across two discussions, off-topic audited silence, the self-loop guard and the webhook gates. The repository holds the scenarios, not their results, so read live-verification status off those runs rather than off this page.\n",
|
|
13
|
+
markdown: "# Notion\n\nConnect Open Everywhere to a Notion doc and it becomes an **ambient teammate that lives in the page**: it silently reads every comment anyone leaves, holds a standing per-doc brain, and steps in **only where it clearly adds something** — answers a question raised in a comment, resolves a decision a thread is circling, flags a factual problem — and stays invisibly (but audibly) silent otherwise. Name it in a comment and it engages; ignore it and it fades into the background, watching and remembering.\n\nThis is the Notion-specific setup runbook. For the concept of a *surface*, see [Surfaces](../../extending/surfaces.md); for how memory is scoped, see [Memory](../../capabilities/memory.md). Notion is comment/discussion-based rather than chat-based (like [Linear](./linear.md)), so it's out of scope for the chat [support matrix](./support-matrix.md).\n\n## How it works — an ambient-judged surface\n\nEvery comment on a connected doc fires a `comment.created` webhook to your instance. That event is a **signal only** (it carries the comment id, not the text), so Open Everywhere does a two-step fetch to retrieve the comment, then a **standing per-doc supervisor session** — the same ambient judge OE's Slack channel supervisor uses — decides whether to engage:\n\n- **It reads every comment.** Most need nothing from it, and the bar to reply is deliberately high (\"when you're unsure whether a message clears that bar, it doesn't\"). It replies only when it clearly adds something the people there couldn't easily get themselves.\n- **A structured @mention guarantees a reply** (the conversation-driver treatment). A **name-drop** — a person typing the agent's display name in a comment — is a *strong input to the judge* (\"addressed to you or names you\"), not a bypass: the wake stays judged.\n- **Silence is audited.** Every judged-silent turn writes a `notion.no_reply` audit row (reason included), so \"why didn't it reply?\" is always answerable — parity with Slack.\n- **On demand it can read the page's discussion** with the generic `read_channel` / `read_thread` verbs — the comment threads and inline discussions on the page, so it can catch up on an argument it wasn't tagged into rather than answering out of context. `read_thread` reads the one discussion the wake is in; `read_channel` reads every discussion on the page, each headed by the block it anchors to.\n- **The doc BODY is a different plane.** The page is the work object, the comment layer is Notion's chat surface, and page prose never rides the chat verbs. The supervisor session reads a body two ways: the domain declares the Notion **work credential**, which mounts `read_notion_page` on the session directly, or the session **delegates to a worker**, which holds `read_notion_page` and `read_notion_comments` on the chat credential. A domain with no work credential and no delegation route reads no page body — that is the ruling, not a gap.\n- **A page body read over the REST skill is taint-marked too.** The `notion-rest` skill's `page markdown`, `blocks list` and `comments list` print their answer inside the skill envelope, source `notion-rest`. The host's `bash` tool fences that span as untrusted content and marks the turn with kind `skill-declared`. No `taint.verdict` row records this mark: that row records only a divergence between the call site and the tier table, or an assumed tier, and `skill-declared` is always untrusted, so the two agree. When a delegated worker made the read, the seat adopts the mark at settlement, and the seat's `delegation.task_settled` row lists `skill-declared` in its `kinds`. So all three roads to a page body mark the turn: `read_notion_page` (kind `notion`), a lent grant tool (kind `connector`), and the REST skill (kind `skill-declared`). Titles, ids, errors and the output of `search`, `page current`, `page get`, `db query` and the raw `api` verb are not wrapped.\n- **A worker on the REST skill reads \"this page\" by name, not by guess.** The wake envelope names the page only as an opaque channel id, by design. So the host writes the page id to `current-page.json` beside the `notion-rest` skill for every session, and sets it only when the session's surface is a Notion page (`notion/<workspace>/<page>`). `node scripts/notion.mjs page current` prints the page's id, title and url, and `page markdown current` reads its body. On any other surface the file holds `null`, and both verbs print \"This conversation is not on a Notion page\" and exit non-zero. The host rewrites the file on every turn, so a unified window that moves off a Notion page never keeps the old id. Two instruction rules close the loop: the seat never puts a page id it inferred (from the channel id or a message id) into a worker brief, and for \"this page\" it tells the worker to read the current page; the worker uses `current` for the page the conversation is on even when the brief names an id, and uses an explicit id only for a different page the person named.\n- **Every read is fenced to the calling session's domain.** A session may read its own page, any page in its domain's `reach`, and — if the domain reaches the whole workspace — any page in that workspace. Anything else is **refused**, including a page whose domain cannot be determined at all: an unassigned page is not readable, it is a refusal. Every refusal writes a `read.refused` audit row naming the page and both domains. (Underneath that sits Notion's own share list, which the next paragraph is careful about.)\n- **Every read is dated.** The generic verbs date every line they return. `read_notion_page` states the read instant and that its text is the page's current content at that moment. `read_notion_comments` and the generic verbs state the same instant and age every comment against it (\"27 minutes before this read\"), and mark the whole comment block HISTORY — a comment's claim about status or approval is not the page's current state; `read_notion_page` is the tool that reads that. Without the contrast the agent can narrate an old comment as the present, the same failure a Linear soak run measured (see [Linear](./linear.md#reading-an-issue)).\n\n**Which domain a comment wakes is fenced on the signer too.** A `comment.created` delivery names a workspace, and nothing proves that name: Notion returns no workspace id on any object, so no lookup can confirm it. The page is different — Open Everywhere establishes it from the comment Notion returns under the verified credential, never from the payload. So the risk is a delivery that pairs an honest page with a **forged workspace**, which would otherwise reach whichever domain claims that workspace in a container `reach` entry. Before the session opens, OE asks the same question the routine plane asks: is the domain the claim reached a domain the **signing integration** serves? If not, the delivery is dropped and a `notion.scope_refused` audit row records the page, the claimed workspace, the domain and both credentials.\n\nTwo consequences worth knowing:\n\n- **A page listed in a domain's `reach` is not fenced this way**, and that is deliberate. Its domain comes from the page id, which is authenticated. A page you share with two integrations produces one delivery per subscription, and each one must still run under the integration that sent it.\n- **`webhooks.allowUnverifiedNotion` identifies no signer**, so with that dev hatch on, a page reached only through a container entry wakes nothing. Name a page outright in `reach` to exercise it locally.\n\n**The session partition's workspace comes from config, not from the delivery.** A delivery's `workspace_id` is unauthenticated and Notion returns no workspace id on any object, so the surface key composes the page with the workspace segment of the `reach` entry that matched. What that buys differs by leg, and the difference is worth knowing:\n\n- **A page named outright** matches by page id alone, so config and the payload can genuinely disagree — and config wins. A stale, mistyped or forged workspace beside such a page can no longer open a second partition beside the honest one and split that page's session and memory history.\n- **A page a container swept in** only matches when the payload's workspace equals the container's own text, so there the two agree by construction. A delivery naming a workspace no domain reaches matches nothing: its key belongs to no domain, gets no memory and no bundle, and the ambient gate refuses it. It does not join the domain under a second key — and Open Everywhere will not invent the domain's workspace for it, because that would hand a forged delivery that domain's brain.\n\nBoth legs rest on one config rule, refused at load: **a domain may reach at most one Notion workspace by container**. A container states no workspace for the pages it sweeps in, so two of them would leave \"which partition?\" to the payload. Name pages outright and a domain may still span workspaces — an exact entry carries its own.\n\nReplies post back into the **same discussion thread** (`POST /v1/comments`).\n\n> The agent improves a doc **by commenting on it**, and reads it to answer. It does **not** edit doc blocks — a write path (with per-action approval) is a deliberate future scope, not v1.\n\n### Naming the agent engages the page\n\nNotion lets nobody @mention an internal integration, so **naming** the agent in a comment is how it\nis addressed here. The agent answers to the Notion integration's own name, plus the configured\n`identity.name` and `identity.shortName` settings — or the brand phrase and `oe` on a deployment that\nconfigures no name. With a name configured, the brand phrase and `oe` no longer address the agent,\nunless the Notion integration's own name is exactly one of them.\nThat name-drop also marks the **page** engaged: later plain comments on it — in any of its\ndiscussions — wake the agent with no second name-drop, because the invitation was the comment\nsomebody already wrote.\n\n- **It needs the page to be in a memory domain, and that is the floor.** A name-drop answers on a\n page one of your domains names outright in `reach` or admits by a container entry, whether or not that domain has\n `ambientSupervisor` on. A page the integration can merely *see*, in no domain at all, still does\n nothing. So this is one door — an addressed page can be addressed — not an open one.\n- **The grain is the page, not the discussion.** One standing session serves the whole page, so a\n stop asked for in one discussion is a stop for all of them.\n- **Asking it to stop works, and it is durable.** Say it in words and the agent calls `stand_down`,\n which stores the stop against the page. It then answers nothing there — including through the\n ambient supervisor below — until somebody names it again. There is no timeout.\n- **A page nobody ever named it on is untouched** by this. It wakes only through the ambient\n supervisor below.\n- **An author your `externalPolicy` excludes cannot engage a page**, and cannot lift a stop by\n naming the agent — their comment is suppressed before the mark, so nothing is written.\n\n### Opt-in is per **domain** (fail-closed off)\n\nThe whole ambient path is **off by default**. It only runs when the Notion doc's memory **domain** has `ambientSupervisor: true` (the same domain flag that turns on Slack's ambient supervisor). Compose a domain, flip ambient once, and every connected doc in it goes ambient — one operator action, then it \"just works\", while staying explicit. Until a domain opts in, a doc in that domain does nothing **unless somebody names the agent on it or has already done so** — the addressed and engaged paths above are the two exceptions, and every other comment is an explicit, audited no-op rather than a silent drop. A doc in **no** domain does nothing at all, name-drop included. A domain can compose docs **across platforms** (Slack channels + GitHub repos + Linear teams + Notion docs share one flag and one silo), and how eagerly the supervisor steps in is tunable per domain with `engagementTier` (Notion defaults `neutral` — see the [configuration reference](../configuration-reference.md#memory-domain--silo)).\n\n## Structural changes can trigger routines — comments cannot\n\nOne Notion webhook subscription now feeds **two** things, and they are split cleanly by event type:\n\n- **Comments are conversation.** `comment.created` / `comment.updated` / `comment.deleted` go to the ambient supervisor above. They can **never** trigger a routine — a comment is a thread the agent participates in, not a job.\n- **Structural changes are work.** `page.*`, `database.*` and `data_source.*` — a page created, moved, deleted, its properties updated, a schema changed — are what a [routine](../../capabilities/proactivity-and-routines.md) can subscribe to with an `event` trigger on source `notion`. The ambient supervisor never sees them.\n\nNo single delivery does both. Nothing new to configure: the subscription and its signing secret are the ones you set up in Step 2.\n\nA subscription's filter is a [jq expression](../../capabilities/proactivity-and-routines.md#event-filters-are-jq), and it **must narrow its scope** — reference `.fields.parent` (the containing database or page), `.fields.entity` (the page itself), or a `.scope.<kind>` level. Naming only the workspace is refused: subscribing to *every* change in a whole Notion workspace has to be spelled out deliberately, not defaulted into. The fields this source flattens out are `event`, `action`, `entity_type`, `workspace`, `parent` and `entity` — e.g. `.fields.parent == \"<database-id>\" and (.fields.action | IN(\"properties_updated\"))`. Notion's scope path is `workspace → teamspace | database/page → page → block`; note that Notion's webhook envelope carries exactly **one** parent, so a delivery proves either the teamspace *or* the containing database, never both.\n\n### 🔴 What authorizes a Notion trigger — and what does not\n\nA routine only fires on a Notion event if **the routine's domain is the domain you put the affected Notion surface in** (the same `domains` registry that turns the ambient supervisor on). The innermost surface the registry knows about decides: if you have placed a page in `payments`, a routine in `security` cannot subscribe to it, even if the *database* that page lives in is in `security`. A page in no domain triggers nothing, anywhere.\n\n**A second check runs first, and it is about who signed the delivery.** A webhook signature proves which subscription's `verification_token` signed the bytes. It does **not** prove that Notion wrote them: whoever holds a token can sign a body naming any workspace, database or page they choose. So before a delivery reaches the routine plane, Open Everywhere reads the domain that owns the named surface and asks whether the **signing integration serves that domain**. A body signed with `payments`'s token that names a `security` page is dropped, and the reason is logged.\n\nThat check is only as strong as your subscription layout, and the rule is exact: **a domain is protected from another domain's forged deliveries only when it runs its own subscription with its own `inboundSecret`.** Two integrations that share one inbound secret sign identically, so the signature cannot tell them apart and the check accepts both. The same rule and the same limit govern the comment plane's fence [above](#how-it-works--an-ambient-judged-surface). If you hand a domain's `verification_token` to that domain's operators, give that domain its own `inboundSecret` at the same time ([below](#step-4--one-integration-per-domain-optional)).\n\n**Be clear about what that check is standing on, and it depends on how you configured the credential.** A Notion credential is an internal-integration secret whose visibility is a share list maintained inside Notion, page by page.\n\n- **On the default setup** — one integration for the whole deployment — that credential is **identical for every domain and cannot express anything narrower than the workspace**, so it is *not* the domain boundary and OE does not present it as one. The per-domain boundary is your `domains` config, and the per-page boundary is Notion's own sharing. Neither substitutes for the other.\n- **When you bind a domain to its own integration** ([below](#step-4--one-integration-per-domain-optional)), that domain's calls run under a credential Notion itself scopes to that integration's share list. The boundary is then **vendor-enforced** for the pages you did not share elsewhere — the strongest isolation any OE surface offers.\n\nEither way the same instruction holds: if you need a Notion page not to reach a given domain, put it in a different domain **and** do not share it with that domain's integration.\n\nThe same reasoning is why `read_notion_page` / `read_notion_comments` carry their own domain fence rather than leaning on the credential. Notion will happily serve any page the integration was shared into, whichever domain is asking, so the domain check has to be ours — and it is applied to the page id the agent asks for, not just to the page it was woken on. The `block` drill-in on `read_notion_comments` is checked the same way: Open Everywhere asks Notion which page owns the block and fences that, and a block it cannot place is refused.\n\n`read_notion_page` also FALLS BACK to that same block-resolution walk on its own `page` argument: the model occasionally hands it a discussion/block id instead of the page it lives on, and rather than refusing outright, OE asks Notion which page owns it and fences **that** page through the identical domain check — never a looser one. Resolution only ever produces an id that still has to clear the same allowlist a directly-passed page id would, so it cannot become a bypass. If the id can't be placed at all, or the page it resolves to is itself out of scope, the read stays refused — and the refusal says so, naming the block-id possibility so the caller can pass the parent page id directly next time.\n\n## How the silo model maps\n\nOpen Everywhere's memory is [opt-in domains and silos](../../capabilities/memory.md), not an\nautomatic per-workspace tier stack. A Notion comment lives on a **page** in a workspace,\ninside a **discussion**. The page is the persistent container that hosts many discussions\nover time, so:\n\n| Open Everywhere concept | Notion thing |\n| --- | --- |\n| `org` (tenant) | your deployment (`deploymentIdentity.org` in `agent/identity/surfaces.ts`, one org per deploy today) |\n| `workspace` (identity key) | the Notion **workspace** (`workspace_id`) |\n| `surface` (partition key) | the **page** the comment lives on (`page_id`) |\n| invocation + post-back anchor | the **discussion** thread (`discussion_id`) |\n| `mode` | `channel` (Notion has no 1:1 DM analog) |\n| `visibility` | `private`, fail-closed (see below) |\n\n**Surface = page, not discussion.** Every discussion on a page shares one memory silo, so the doc brain carries context across all of a page's threads instead of fragmenting per thread.\n\n### The \"doc brain\" needs a workspace vouch\n\nVisibility **fails closed**. Notion's webhook carries no reliable \"this page is a broadly-shared internal space\" signal, so `bindNotion` pins `visibility=private` and leaves trust undefined → the session falls closed to `external`, and the **shared page silo is gated off** unless the operator explicitly **vouches the workspace** (`OE_TRUST=internal`, or a per-workspace vouch in `surfaces.ts`). Ambient does **not** relax this — reading more comments never grants more trust.\n\nConsequence: unvouched, the agent still judges and replies, but **without a shared doc-brain silo**. The full \"remembers every prior discussion on this doc\" experience lights up only once you vouch the workspace — one deliberate operator action.\n\n## Prerequisites\n\n- **Admin** on the target Notion workspace (to create an internal integration and subscribe a webhook).\n- A reachable Open Everywhere instance (public HTTPS, or local with a tunnel). The Notion webhook route is **`/eve/v1/notion`**.\n\n## Step 1 — Create an internal integration\n\n1. Go to **notion.so/my-integrations → New integration** (internal). Name it (e.g. `Open Everywhere`). Under **Capabilities**, tick exactly three: **Read content**, **Read comments** and **Insert comments**. The comment surface uses all three — it reads the page (`Read content`), reads the discussion (`Read comments`), and posts and edits its own reply (`Insert comments`, which also covers `PATCH /v1/comments/{id}`). Leave **Update content** and **Insert content** off: Open Everywhere never writes a page block. Leave the user-information options off too: the only user it reads is its own bot (`GET /v1/users/me`), which needs no user capability. An integration that omits **Read content** still replies, but every page read fails — the mistake looks like a broken read tool, not a missing capability.\n2. Copy the **Internal Integration Secret**. Load it into the credential store: `oe secret set db:notion:channel-key` (value on stdin — see [`oe secret`](../setup-runbook.md#oe-secret--the-operator-door-day-0-no-surface-required)). This backs every REST call the comment surface makes: retrieving comments, listing a discussion, reading a page, posting replies. There is no fallback — an unset `db:notion:channel-key` fails the comment surface closed.\n3. **Share the docs (or a workspace parent page) with the integration** (a page's `•••` → *Connections* → your integration). An internal integration only sees pages explicitly shared with it — sharing a page is itself the connect action.\n\n> **Migrating from an env var?** Before OPE-408, this integration secret was read from `NOTION_CHANNEL_API_KEY`. That env var is no longer read by anything — the comment surface now resolves only `db:notion:channel-key`. If your deployment still carries the old env var, this is a **silent break**: the webhook still `200`s, but the reply never posts, and the session log carries a single line — \"reply couldn't post to Notion — no value stored under db:notion:channel-key\". The fix is one command: `oe secret set db:notion:channel-key` with the same value the env var held, then you can delete the env var.\n\n## Step 2 — Subscribe the webhook + complete the handshake\n\n1. In the integration's **Webhooks** tab, add a subscription with the URL:\n\n ```\n https://<your-deploy-url>/eve/v1/notion\n ```\n\n Subscribe to the **`comment.created`** event.\n2. Notion sends a one-time **verification** POST (`{ \"verification_token\": \"…\" }`). That token is the **permanent HMAC signing key** for every future delivery, so Open Everywhere **never prints it** — a signing key in a log is forgeable by anyone with log access for as long as logs are retained. Instead it stores the value encrypted and logs a redacted line with a fingerprint and a **one-shot, 15-minute link**:\n\n ```\n [open-everywhere:notion] handshake secret for Notion webhook (verification_token) received and\n STORED — NOT logged (sha256 fingerprint 1a2b3c4d; confirm your env var with:\n printf %s \"$SECRET\" | sha256sum | cut -c1-8). Read it ONCE here, within 15 minutes — the link is\n single-use and whoever opens it first gets the secret, so treat it as the secret itself:\n https://<your-deploy-url>/connect/v1/reveal?t=…\n ```\n\n Open the link, copy the value, then do **both**: paste it into Notion's verify form to confirm the endpoint, **and** load it into the credential store with `oe secret set NOTION_WEBHOOK_SECRET` (value on stdin). A `NOTION_WEBHOOK_SECRET` **environment variable** of the same name still works and is the migration on-ramp; the stored value wins when both exist.\n\n > **The link is bearer-only.** It is bounded by *single-use* and a *15-minute TTL* — not by identity, because at handshake time there is no one to bind it to. Treat the log line as sensitive until you have redeemed it, and redeem it promptly.\n\n Confirm afterwards that what you pasted is what Notion sent, without exposing it:\n\n ```bash\n printf %s \"$NOTION_WEBHOOK_SECRET\" | sha256sum | cut -c1-8 # must equal the logged fingerprint\n ```\n\n **Requirements and recovery.** The reveal page is armed only when the credential vault is armed (`OE_CREDENTIAL_ENCRYPTION_KEY`) and a public base URL (`OE_PUBLIC_BASE_URL`, else the Vercel deployment URL) is set — its own HMAC secret (`OE_INTAKE_PAGE_SIGNING_KEY`) mints itself on first use and needs no operator step (see the [configuration reference](../configuration-reference.md)). Without those two, the token is still captured and still not logged; the log tells you to configure them. If the link expires, is used by someone else, or you lose the value: **delete and recreate the Notion subscription** so a fresh handshake fires. There is no re-reveal, by design — a re-reveal path would make every stored key a standing disclosure oracle, and in the audit log an authorised re-read is indistinguishable from an exfiltration.\n\n### Before you resume a paused subscription\n\nA verified delivery is acknowledged `200` only after its projected, content-minimal event has been\ncommitted to the operational store. Processing starts immediately, while the mounted\n`notion-ingress` schedule retries due work every minute after a process exit or failed self-call.\nProduction therefore needs a **persistent operational store**, `OE_CREDENTIAL_ENCRYPTION_KEY`, and\na resolvable deployment URL (`OE_PUBLIC_BASE_URL`, or Vercel's injected deployment URL). The\ninternal drain's HMAC key is minted into the existing credential vault; do not create or distribute\nanother secret. On Vercel, a fresh production deploy registers the schedule; run\n`npm run deploy:verify` after deployment so its cron-registration check can compare the deployed\ncron set with the build. On a self-host, run the compiled app through `eve start`, which starts the\nmounted schedule; a bespoke HTTP-only route host does not run schedules.\n\nIf you paused the Notion subscription for an upgrade, keep it paused until the new deployment boots,\nthe operational store is persistent and reachable, the self-call URL resolves to that deployment,\nand the cron-registration check passes. Then resume the existing subscription; do not recreate it\nor rotate its signing secret merely to resume delivery.\n\nDelivery is **at least once**. Pending payloads have no time-based expiry, so an extended outage\ndoes not silently delete accepted work. Completion erases the payload and retains only a dedupe\ntombstone for 24 hours. A crash after Eve accepted a comment but before completion can produce a\nduplicate turn after the five-minute lease expires; the comment-level guard suppresses ordinary\nredelivery for ten minutes. A refusal before dispatch removes the pending row, so a later vendor\nredelivery can be reconsidered after configuration or credentials are repaired.\n\n## Step 3 — Configure the environment\n\nBoth secrets are already handled: the integration secret lives in the credential store (Step 1.2: `oe secret set db:notion:channel-key`), and the handshake secret is captured and stored automatically (Step 2.2). Nothing here is a secret — everything below is optional:\n\n```bash\nNOTION_BOT_USER_ID=… # the ORG-DEFAULT integration's own user id — self-reply loop guard.\n # Auto-resolved via GET /v1/users/me; set it to skip that call (or in\n # tests). It pins the `channel-key` integration ONLY: a domain that binds\n # its own integration always resolves that one live, because two Notion\n # integrations never share a bot user.\nNOTION_BOT_NAME=… # the integration's display name — the name-drop judge input. Auto-resolved too.\nNOTION_VERSION=2022-06-28 # Notion API version header (default shown)\nOE_TRUST=internal # VOUCH the workspace so the shared doc-brain silo lights up (see above)\n# DEV ONLY, and it is a SETTING not a variable: turn on `webhooks.allowUnverifiedNotion`\n# in agent/settings/settings.ts to accept unsigned deliveries for local smoke tests.\n```\n\n**Then turn ambient on for the doc's domain** (`ambientSupervisor: true` on the memory domain — see [config-as-code](../../how-it-works/config-as-code.md) / [Memory](../../capabilities/memory.md)). Without it, the surface stays silent.\n\n## Step 4 (optional) — Notion as a **work surface**\n\nSteps 1–3 wire Notion as a **chat surface**: the agent is woken by a comment, reads that page, and replies there. It cannot read a Notion page from a Slack conversation, and a worker it delegates to cannot either. That is deliberate — capability comes from what the **domain** holds, not from the surface the conversation happens on.\n\nTo let a domain **work** on Notion, declare its Notion work credential:\n\n```ts\ndefineDomain({\n name: \"payments\",\n reach: [\"slack/T…/C…\", \"notion/<workspace>/<page>\"],\n credentials: [\n // The GRANT. `credential: \"team-key\"` names Notion's WORK role; `use` names the\n // credential id this domain's Notion work calls run under.\n { connector: \"notion\", credential: \"team-key\", use: \"payments-notion-key\" },\n ],\n});\n```\n\nWhat the declaration turns on, for **every** surface in that domain:\n\n| tool | what it does |\n|---|---|\n| `notion_search` | Find pages and databases by title and text. Returns ids + titles, never page content. Mounted **only** under this grant. |\n| `read_notion_page` | Read a page the domain owns — now from a Slack, GitHub, Linear, Teams or Discord session too, not only from a Notion wake. |\n\nFour rules govern it:\n\n- **The work credential needs ONE capability: Read content.** Both tools only read — `notion_search` calls `POST /v1/search`, `read_notion_page` calls `GET /v1/pages` and `GET /v1/blocks`. Tick no comment capability and no write capability on a work integration. A work integration that carries either is wider than this grant can use.\n- **Absence is a refusal, not a default.** A domain that declares nothing for the work role holds no work grant, so no Notion work tool mounts on any of its sessions. This is the opposite of the chat/plumbing roles, where \"no binding\" means \"use the deployment default\".\n- **Use a separate integration.** The chat credential (`db:notion:channel-key`) should be shared with the comment pages only. The work credential should be shared with the page trees the agent may build from. A chat integration that can also write your whole workspace is the hole this split closes — Open Everywhere ships the seam, your organisation scopes the keys.\n- **Search's reach is the credential's reach.** Open Everywhere does not filter Notion search results. Notion enforces the boundary itself (an internal integration sees only the trees a human shared with it), and a filter on top would read as a boundary while being none. Narrow the search by sharing a narrower integration. `read_notion_page` is separately fenced to pages the caller's own domain owns, so finding a page is not the same as being able to read it.\n- **A refused work-plane read is audited as a Notion refusal, not a Slack (or GitHub, or Linear, or Teams) one.** The `read.refused` row's `platform` column always names the read's subject — `\"notion\"` — because the row is about a refused Notion page, chat plane or work plane alike. On the work plane the caller is a different platform's surface, so its `workspace`/`surface` columns would show that platform's ids under a `platform: \"notion\"` row, which reads as a bug. They are stamped `\"unknown\"` instead; the caller's real identity is never lost, because `detail.callerSurfaceKey` always carries the whole `platform/workspace/surface` string.\n\nWork tools are the **worker's**, not the seat's: a conversational seat delegates, and the delegated worker mounts them. See [connectors and providers](../../capabilities/connectors-and-providers.md#chat-surface-vs-work-surface).\n\n**Identity binds automatically.** You do not set workspace / surface / mode / visibility by hand. The channel takes the **page**, the **discussion** and the **commenting actor** from the comment Notion returns under the verified credential, takes the **workspace** from the delivery body (the vendor exposes no other source, so the fence above stands in for a proof), and `bindNotion` derives the rest.\n\n## Step 4 — One integration per domain (optional)\n\nBy default one Notion integration serves every domain. That is fine for most deployments, and it is\nthe only setup the steps above need. It also means the credential is **identical for every domain**,\nso the domain boundary is OE's config alone.\n\nNotion supports **many** integrations, and each one only sees the pages a human shared with it. Bind\na domain to its own integration and Notion itself enforces the boundary for that domain's pages.\n\n**1. Create a second integration** (Step 1), share **only** that domain's pages with it, and store\nits secret under its own name:\n\n```bash\noe secret set db:notion:payments-channel-key # value on stdin\n```\n\n**2. Declare it as a credential** on the notion catalogue in `agent/access/connector-credentials.ts`, beside the\ndefault `channel-key`:\n\n```ts\n{ id: \"payments-channel-key\", type: \"api-key\", secretRef: \"db:notion:payments-channel-key\", broker: \"db\", intake: false },\n```\n\n**3. Subscribe its own webhook** to the SAME URL (`/eve/v1/notion`) and complete the handshake\n(Step 2). Each integration issues its **own** `verification_token`, so store this one under its own\nname too:\n\n```bash\noe secret set NOTION_WEBHOOK_SECRET_PAYMENTS # value on stdin\n```\n\n**4. Bind the domain** in your `domains` config:\n\n```ts\ndefineDomain({\n name: \"payments\",\n reach: [\"notion/<workspace>/<page>\"],\n ambientSupervisor: true,\n credentials: [\n { connector: \"notion\", credential: \"channel-key\", use: \"payments-channel-key\", inboundSecret: \"NOTION_WEBHOOK_SECRET_PAYMENTS\" },\n ],\n}),\n```\n\n**What then happens.** Reads and posts an **agent-initiated** turn makes for a page in `payments` —\nno delivery woke it, e.g. a routine's proactive post — run under `payments-channel-key`. Every other\ndomain keeps using `channel-key`. A **delivery-woken** session's `read_notion_page` /\n`read_notion_comments` calls follow the rule two paragraphs down instead: the credential that\nverified the delivery, not the domain's binding. The `memory` role is unaffected either way — see\nthe note below.\n\n**An inbound delivery is decided by its signature, not by the page it names.** Every configured\nintegration's secret is tried, and the one that verifies identifies the sender — so both\nsubscriptions work on one endpoint with no routing table. That verifying integration then serves\nthe whole delivery: the two-step fetch, and the reply, which posts under the same credential that\nfetched. Nothing in the payload steers this, deliberately — a delivery's page id is unauthenticated,\nso if the page's binding outranked the signature, anyone holding one integration's\n`verification_token` could name another domain's page and have OE act with that domain's key.\n\n**The page the session lands on comes from the same place.** OE does not read it off the payload\neither. It reads the comment under the verifying integration's own key and takes the page from what\nNotion answers — the comment's page parent, or, for an inline comment, the page that owns the\ncommented block. That page decides the session's domain. `read_notion_page` / `read_notion_comments`,\nthough, run under the **verifying credential itself** — the one that fetched and will reply — never a\nfresh lookup off that domain's binding. One rule for the whole delivery: the credential that verified\nserves the fetch, those two read tools, and the reply alike. A delivery whose page will not resolve\nthat way is acknowledged and dropped; the payload's page id is never the fallback.\n\n**The `memory` role does not follow that rule.** A domain's Notion memory silo (its `memory`\ncredential, distinct from `channel-key` — see [Memory backings bind separately](#step-4--one-integration-per-domain-optional)\nbelow) still resolves off the **domain binding**, whichever integration verified the delivery. So a\ndouble-shared page's session reads its doc body and comments under the narrower, verifying key, but\nstill draws its shared brain — if any — under the domain's own memory integration.\n\nIn the configuration this step describes, the two answers are the same one: unshare the payments\npages from the default integration and the default subscription never sees them. They differ only\nwhile a page is shared with both integrations — and then each subscription's delivery correctly runs\nunder its own integration, which is what keeps both working.\n\n### The rules worth knowing before you rely on it\n\n- **A domain with no binding uses the org default.** That is the fallback, not a misconfiguration.\n You never have to bind every domain to bind one.\n- **A binding is never downgraded.** If `use` names a credential the provider does not declare, the\n deployment **refuses to boot** and names the domain. If the credential is declared but its secret\n is unset, that domain's Notion calls fail closed — they do **not** retry under the org key. The\n self-reply loop guard treats such an integration as unable to post **from this deployment**, so it\n does **not** make the rest of the deployment deaf; an integration whose key WORKS but whose\n `GET /v1/users/me` fails does — see the loop-guard bullet under [Gotchas](#gotchas).\n- **The vendor fence is only as good as your share lists.** Binding `payments` changes which key OE\n calls with; it cannot un-share a page. If the page is also shared with the default integration,\n that key can still read it — and the default subscription will also deliver that page's comments,\n which OE then serves under the default key, because that is the integration that proved it sent\n them. A comment on it wakes a session that surfaces on that page, so the session sits in the\n `payments` domain — but its `read_notion_page` / `read_notion_comments` calls run under the\n **default** key, because that is the credential that verified this particular delivery, not the\n domain's own `payments-channel-key`. So the cost of a double-shared page, for those two tools,\n stays what Notion's own share list says the default integration can see. The domain's `memory`\n silo is unaffected by which integration verified the delivery — it still pulls under whatever\n credential the domain's own `memory` role names (a separate binding from `channel-key`), so a\n double-shared page narrows the read tools' reach but not the memory silo's. Unshare it anyway:\n while it stays shared, the weaker default key is a second door onto that one page.\n- **`omit inboundSecret`** when several domains sit behind ONE subscription — they then share the\n deployment's default signing secret. Name your own the moment you create a second subscription.\n- **Each integration has its own bot user**, so the self-reply loop guard resolves and caches one\n identity per integration. 🔴 **`NOTION_BOT_USER_ID` pins the ORG-DEFAULT integration only, and that\n is deliberate.** Two Notion integrations never share a bot user, so one pinned id cannot stand for\n both; OE resolves every other integration live and the variable has no effect on them. The guard\n compares a comment against **every**\n configured integration's bot id, so two integrations that can both see one page do not answer each\n other: a reply one posts is recognised as the deployment's own words on the other's subscription.\n The cost is one `GET /v1/users/me` per integration, on the first delivery each process handles.\n- 🔴 **One integration whose bot id will not resolve makes the deployment deaf to every Notion\n comment Notion does not mark as person-authored — addressed ones included.** The guard fails\n closed: with an id missing it cannot tell that integration's own reply from a person's, because\n Notion omits `type` on a comment's author. It therefore drops every comment Notion does not\n explicitly mark as person-authored, until the id resolves. 🔴 **That is not only ambient chatter.**\n The check runs before the addressed arms, so during such an episode a name-drop that asks the agent\n directly, and an `oe link <code>` identity claim, are dropped too. The failure is\n cached for the life of the process, so a credential you fix takes effect on the next deploy or\n restart. **Check every integration's token after you add one** — an integration listed with a\n signing secret but no working token is the shape that trips this.\n- **Memory backings bind separately, on their own role.** The `notion` memory projection has its own\n credential role, `memory`, distinct from `channel-key`. Override it the same way:\n `credentials: [{ connector: \"notion\", credential: \"memory\", use: \"payments-memory-key\" }]`. A\n domain may bind one role, both, or neither — an operator often shares a domain's BRAIN pages with\n one integration and its WORK pages with another. Unbound domains read the brain under the org\n default (`db:notion:memory`), as before.\n- **A shared silo reads under EACH lister's own credential in a session.** A silo two domains both\n list is read, in a session, under the credential of whichever domain's `memory` list named it —\n so a bound domain pulls the shared subtree under its OWN integration. Share the silo's pages with\n every bound lister's integration, or its brain reads empty for that domain. Only the silo-first\n readers (the control plane, the tidy schedule, the purge worker) fall back to the org default for\n a silo with two owners.\n\n## Gotchas\n\n- **Ambient is off until a domain opts in.** A connected doc does nothing until its memory domain sets `ambientSupervisor: true`. If comments get no response, you no longer have to guess: the first delivery from a page the supervisor was never asked about writes a **`notion.ambient_skipped`** audit row naming the surface key, the reason (`no-domain` — nothing routes this page — versus `domain-refused` — a domain does route it but hasn't opted in) and the exact edit to make. Query it with `GET /control/v1/audit?since=<iso>&actions=notion.ambient_skipped`.\n- **The audit trail says whether a page is engaged.** Each dispatched comment writes a **`notion.dispatched`** row whose `detail.addressing` reads `direct` (somebody named the agent), `thread-follow` (a plain comment on a page an earlier name-drop engaged), or `channel-watch` (a plain comment on a page nobody has named the agent on). Query it with `GET /control/v1/audit?since=<iso>&actions=notion.dispatched`. 🔴 **`thread-follow` reports the engagement registry's state, not which door admitted the delivery.** The registry is read for every plain comment, before and independently of the ambient opt-in — so on a domain with `ambientSupervisor: true`, plain chatter on an engaged page reads `thread-follow` too, while the ambient flag alone would have admitted it. The row separates the engaged path from the ambient one only where the domain has ambient **off**. The same row carries **`detail.via`** — how your config holds the page: `roster` (a domain lists the page id under `reach`), `container` (a domain's container `reach` entry reaches it, so the page needed no entry of its own), or `none` (no domain holds it). Read it beside `addressing`: a `direct` wake with `via: container` means a claim admitted the name-drop on a page nobody enumerated, which is a wider door than a page you listed. 🔴 **It names the KIND of claim, never its breadth.** A container `reach` entry is either a whole workspace (`notion/<ws>`) or a same-level page glob (`notion/<ws>/<prefix>*`), and both read `container` — check the domain's own entry to see how far the claim reaches.\n- **The doc brain needs a workspace vouch.** Unvouched (the default), the agent judges + replies but has no shared page-memory silo — see [the doc-brain section](#the-doc-brain-needs-a-workspace-vouch). Set `OE_TRUST=internal` (or vouch the workspace) to unlock it.\n- **Self-reply loop guard (hardened), in two halves.** The agent reads its *own* replies (each reply fires a fresh `comment.created`), so both halves have to hold.\n - *Whose words.* It drops **its own** replies by comparing `created_by.id` against the bot user id of **every configured integration**, not only the one that verified the delivery — so two integrations that can both see one page do not answer each other. It **fails closed on every unreadable identity**: the verifying integration's own id unresolved, a comment with no `created_by.id`, and — on an author Notion does not mark `person` — a *sibling* integration's id unresolved. An unreadable identity drops the comment rather than risk looping on its own replies. If nothing ever dispatches, confirm the value stored under `db:notion:channel-key` is valid (so `GET /v1/users/me` can resolve the bot id) or set `NOTION_BOT_USER_ID` — **and check every other integration's stored key too**, because a sibling whose key works but whose `GET /v1/users/me` fails silences the whole deployment until it resolves. A sibling **this deployment holds no usable key for** cannot post from here, so it is skipped rather than fenced against — the residual being a token you deleted here that another environment still holds, whose replies then read as a person's. 🔴 **A FOREIGN bot — one that is not ours — is still dropped only when Notion supplies `created_by.type`, which it usually omits on a comment.** That limit is unchanged.\n - *How many times.* **One comment id normally wakes one turn**, with a durable ten-minute dispatch guard shared across instances. The webhook is acknowledged only after durable acceptance, and pending work is retried by the minute recovery schedule. The contract is at least once: a crash after Eve accepts the turn but before the guard and inbox completion are recorded may cause a duplicate after the five-minute lease expires. This prefers a possible duplicate over silently stranding an accepted comment.\n- **Identity-linking a Notion account needs the workspace vouched.** Notion has no @mention for an integration, so you claim a link code by **name-dropping** the agent — a comment whose content is `<your agent's name> link ABC12345` (`oe link ABC12345` on a deployment that configures no name). That is recognised only on an **addressed** (name-drop) comment, and — like the doc brain — only completes when the commenting account resolves **internal**: vouch the workspace (or set `OE_TRUST=internal`). Unvouched, the claim is refused (the author reads `external`), the same fail-closed posture that gates shared memory. The names come from the `identity.name` setting (the canonical name the agent introduces itself by), the `identity.shortName` setting, and the integration's own resolved display name. The brand tokens `oe` / `open everywhere` apply only on a deployment that configures no name.\n- **Silence is a first-class outcome.** Most comments correctly get no reply. Every silent turn is a `notion.no_reply` audit row — the surface is working, not broken. The engagement bar ships deliberately silence-biased; tune it by watching the live `no_reply` rate (no code change). A **different** quiet outcome is a `turn.supervisor_prose_dropped` row on the notion partition: the agent wrote an answer as its turn's closing text and never called `reply`, so nothing was posted. The `reply` tool is the only way words reach a discussion — closing text is never a delivery channel. That row is a defect signal, not a tuning signal — read the session transcript it names.\n- **Signing-secret verification is fail-closed.** Every real delivery is HMAC-verified against `NOTION_WEBHOOK_SECRET` (the stored secret, else the env var of that name). Unset or wrong → deliveries are rejected (401) and the agent stays silent. With several integrations configured, OE tries each one's secret and the one that verifies identifies the sender; a delivery no configured integration can verify is rejected. Running locally? Expose your instance with a tunnel and use that HTTPS URL.\n- **`webhooks.allowUnverifiedNotion` never fires a routine.** The dev hatch lets an *unsigned* delivery reach the ambient supervisor so you can smoke-test a laptop; it deliberately does **not** admit that delivery into the routine-trigger plane. An acceptance whose only proof is possession of the URL must not be able to start member-authored work. It also identifies **no signer**, so a page a domain reaches only through a container `reach` entry is refused under the hatch — list that page in the domain's `reach` to exercise it locally.\n- **A delivery can be refused for naming the wrong workspace.** If one domain's pages go quiet while another domain's work fine, read `GET /control/v1/audit?since=<iso>&actions=notion.scope_refused`. Each row names the page, the claimed workspace, the domain the claim reached and both credentials. The usual honest cause is a subscription whose integration does not serve the domain that claims the workspace: either bind that domain to the integration that actually delivers, or give the domain its own subscription.\n- **No ticket-action verbs (`set_state`/`assign`/`add_label`).** Unlike Linear/GitHub, a Notion\n page has no typed status/label/assignee schema OE can map to those verbs generically (a\n database's Status property shape is per-database, not a platform convention) — see\n [Source control § Ticket actions](../../capabilities/source-control.md#ticket-actions-set_state--assign--add_label).\n Called against a Notion ref, all three refuse cleanly (\"not supported on notion\") rather than\n guess at a mapping. A future Notion-database-aware mapping is possible but not built.\n- **No `create_ticket` either**, for the same reason: filing a \"ticket\" into Notion means creating\n a database row, and which property is the title (and which is status) is per-database, not a\n platform convention. Notion registers no create adapter, so `create_ticket` refuses on it\n **naming the surfaces that do work** (GitHub, Linear) instead of inventing a row shape — see\n [Source control § Filing new work](../../capabilities/source-control.md#filing-new-work-create_ticket).\n- **Accountability is OE-audited-only.** Every turn (dispatch, reply, and judged silence) is in OE's append-only audit log. Notion's own audit log is Enterprise-admin-only and not queryable by an integration, so OE's log is the sole record. This is the accepted interim tier until Notion's waitlisted **External Agents API** (a first-class, Notion-audited agent participant with native HITL) lands.\n\n## Status\n\nBuilt, unit-tested, and driven by the `J-NOT` end-to-end scenario in `packages/core/scripts/e2e/scenarios.ts` (`suite: \"core\"`, non-optional, deterministic — `judged: false` — gated on the `notionAmbient` fixture), with `J-ENGAGED-notion` covering the engaged-follow-up seam. `J-NOT` drives the on-topic anchor reply, reply-targeting across two discussions, off-topic audited silence, the self-loop guard and the webhook gates. The repository holds the scenarios, not their results, so read live-verification status off those runs rather than off this page.\n",
|
|
14
14
|
},
|
|
15
15
|
];
|
|
@@ -35,7 +35,6 @@ export interface NotionInboundAnswer {
|
|
|
35
35
|
readonly requestId: string;
|
|
36
36
|
readonly floor: string;
|
|
37
37
|
readonly wakeId?: string;
|
|
38
|
-
readonly taskId?: string;
|
|
39
38
|
}
|
|
40
39
|
/**
|
|
41
40
|
* Is this inbound comment the answer to the session's outstanding question, and may THIS discussion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notion-question-return.d.ts","sourceRoot":"","sources":["../../../agent/channels/notion-question-return.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"notion-question-return.d.ts","sourceRoot":"","sources":["../../../agent/channels/notion-question-return.ts"],"names":[],"mappings":"AAiFA;oGACoG;AACpG,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/C;AAED;8FAC8F;AAC9F,wBAAgB,0BAA0B,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEvE;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAGzF;AAQD;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAAC,KAAK,EAAE;IAClD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B,GAAG,OAAO,CAAC,OAAO,CAAC,CAQnB;AAED,kFAAkF;AAClF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE;IACtD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC,CA6C3C;AAED,gGAAgG;AAChG,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAI3F"}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
// THE NATIVE ROUTE'S RETURN LEG (OPE-1305) — how a discussion's next comment
|
|
2
|
-
//
|
|
1
|
+
// THE NATIVE ROUTE'S RETURN LEG (OPE-1305) — how a discussion's next comment answers the session's
|
|
2
|
+
// pending question by request id, after the room and floor check.
|
|
3
|
+
//
|
|
4
|
+
// 🔴 WHAT IT SERVES NOW (OPE-2351). It was built for a DELEGATED WORKER's question, as told below. A
|
|
5
|
+
// Notion session no longer has children: only the work session holds `agent`. The question it serves
|
|
6
|
+
// is the session's OWN. The leg delivers an ADMITTED answer from the asking discussion by request id,
|
|
7
|
+
// and a guest's answer carries the guest's nonmember stamp (OPE-2348, core hooks/held-question.ts).
|
|
8
|
+
// ⚠️ IT IS NOT A FENCE. A comment from another discussion on the page, or from a commenter the floor
|
|
9
|
+
// refuses, falls to a plain send with no `__eveInputText` override, and eve may take it as the answer.
|
|
10
|
+
// The unified channel closes that with `ordinaryWakeSendOptions`; this route does not. The history
|
|
11
|
+
// below is kept for the mechanism.
|
|
3
12
|
//
|
|
4
13
|
// WHAT WAS BROKEN, AND WHY IT WAS NOT FIXED BY THE HANDLER THAT POSTS THE QUESTION. OPE-1275 gave
|
|
5
14
|
// Notion's channel a real `input.requested` handler: an `ask_question` now lands in the discussion
|
|
@@ -165,7 +174,6 @@ export async function resolveNotionInboundAnswer(input) {
|
|
|
165
174
|
requestId: routing.requestId,
|
|
166
175
|
floor: routing.floor,
|
|
167
176
|
...(routing.wakeId !== undefined ? { wakeId: routing.wakeId } : {}),
|
|
168
|
-
...(routing.taskId !== undefined ? { taskId: routing.taskId } : {}),
|
|
169
177
|
};
|
|
170
178
|
}
|
|
171
179
|
catch {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notion-question-return.js","sourceRoot":"","sources":["../../../agent/channels/notion-question-return.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"notion-question-return.js","sourceRoot":"","sources":["../../../agent/channels/notion-question-return.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,kEAAkE;AAClE,EAAE;AACF,qGAAqG;AACrG,qGAAqG;AACrG,sGAAsG;AACtG,oGAAoG;AACpG,qGAAqG;AACrG,uGAAuG;AACvG,mGAAmG;AACnG,mCAAmC;AACnC,EAAE;AACF,kGAAkG;AAClG,mGAAmG;AACnG,oGAAoG;AACpG,qGAAqG;AACrG,mGAAmG;AACnG,kGAAkG;AAClG,gGAAgG;AAChG,oGAAoG;AACpG,8DAA8D;AAC9D,EAAE;AACF,qGAAqG;AACrG,kGAAkG;AAClG,wFAAwF;AACxF,EAAE;AACF,8EAA8E;AAC9E,mGAAmG;AACnG,mGAAmG;AACnG,mGAAmG;AACnG,mGAAmG;AACnG,qGAAqG;AACrG,8CAA8C;AAC9C,EAAE;AACF,mGAAmG;AACnG,sGAAsG;AACtG,+EAA+E;AAC/E,8FAA8F;AAC9F,oGAAoG;AACpG,qGAAqG;AACrG,EAAE;AACF,mGAAmG;AACnG,qGAAqG;AACrG,gGAAgG;AAChG,qCAAqC;AACrC,sGAAsG;AACtG,sEAAsE;AACtE,mFAAmF;AACnF,qGAAqG;AACrG,kGAAkG;AAClG,mGAAmG;AACnG,kGAAkG;AAClG,2BAA2B;AAC3B,EAAE;AACF,mGAAmG;AACnG,kGAAkG;AAClG,oGAAoG;AACpG,mGAAmG;AACnG,gGAAgG;AAChG,oGAAoG;AACpG,oFAAoF;AACpF,EAAE;AACF,mGAAmG;AACnG,+FAA+F;AAC/F,qGAAqG;AACrG,kGAAkG;AAClG,sDAAsD;AAEtD,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,QAAQ,EACR,qBAAqB,EACrB,mBAAmB,EACnB,mBAAmB,EACnB,uBAAuB,EACvB,gBAAgB,EAChB,2BAA2B,GAE5B,MAAM,4CAA4C,CAAC;AAQpD;8FAC8F;AAC9F,MAAM,UAAU,0BAA0B,CAAC,YAAoB;IAC7D,OAAO,UAAU,YAAY,EAAE,CAAC;AAClC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAc,EAAE,UAAmB;IACnE,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IACtE,OAAO,UAAU,CAAC,CAAC,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;AAC7D,CAAC;AAED,yEAAyE;AACzE,SAAS,oBAAoB,CAAC,IAAkC;IAC9D,MAAM,CAAC,GAAG,IAAI,EAAE,UAAU,EAAE,CAAC,eAAe,CAAC,CAAC;IAC9C,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AAC/D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAAC,KAO5C;IACC,MAAM,GAAG,GAAG,iBAAiB,CAAC,KAAK,CAAC,eAAe,EAAE,IAAI,CAAC,CAAC;IAC3D,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IACpC,OAAO,uBAAuB,CAAC,mBAAmB,EAAE,EAAE,KAAK,CAAC,GAAG,EAAE,GAAG,EAAE;QACpE,SAAS,EAAE,KAAK,CAAC,SAAS;QAC1B,YAAY,EAAE,0BAA0B,CAAC,KAAK,CAAC,YAAY,CAAC;QAC5D,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAClD,CAAC,CAAC;AACL,CAAC;AASD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAAC,KAKhD;IACC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,iBAAiB,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QACtD,IAAI,GAAG,KAAK,SAAS;YAAE,OAAO,SAAS,CAAC;QACxC,MAAM,YAAY,GAAG,oBAAoB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,OAAO,GAAG,MAAM,2BAA2B,CAC/C;YACE,sFAAsF;YACtF,yDAAyD;YACzD,gBAAgB,EAAE,KAAK;YACvB,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,0BAA0B,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACpF,EACD;YACE,IAAI,EAAE,GAAG,EAAE,CAAC,mBAAmB,CAAC,mBAAmB,EAAE,EAAE,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC;YACtE,KAAK,EAAE,KAAK,IAAI,EAAE;gBAChB,0FAA0F;gBAC1F,yFAAyF;gBACzF,qCAAqC;gBACrC,MAAM,QAAQ,GAAG;oBACf,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;oBAC3B,OAAO,EAAE,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,KAAK,CAAC,IAAI,EAAE,EAAE;iBACb,CAAC;gBAChC,MAAM,CAAC,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC;gBACrC,OAAO,kBAAkB,CAAC;oBACxB,GAAG,EAAE,CAAC,CAAC,GAAG;oBACV,QAAQ,EAAE,CAAC,CAAC,QAAQ;oBACpB,SAAS,EAAE,CAAC,CAAC,SAAS;oBACtB,GAAG,CAAC,CAAC,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;oBAC1D,UAAU,EAAE,CAAC,CAAC,UAAU;oBACxB,WAAW,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC,WAAW,IAAI,EAAE;oBACjD,SAAS,EAAE,CAAC;iBACb,CAAC,CAAC;YACL,CAAC;SACF,CACF,CAAC;QACF,IAAI,OAAO,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QAChD,OAAO;YACL,SAAS,EAAE,OAAO,CAAC,SAAS;YAC5B,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACpE,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,+FAA+F;QAC/F,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,gGAAgG;AAChG,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,GAAW,EAAE,SAAiB;IACvE,MAAM,GAAG,GAAG,iBAAiB,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;IAChD,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IACpC,OAAO,qBAAqB,CAAC,mBAAmB,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;AAChE,CAAC"}
|
|
@@ -24,8 +24,8 @@ export interface NotionClassification {
|
|
|
24
24
|
* The Notion synthetic-mention (name-drop) convention. Notion CANNOT @mention an internal
|
|
25
25
|
* integration (notion-webhook.ts — the structured `mentionsUser` path is STRUCTURALLY DEAD for the
|
|
26
26
|
* agent), so a person addresses the doc agent by NAME instead: the deployment's configured names
|
|
27
|
-
* (
|
|
28
|
-
* or the platform-resolved integration name (botName). This is the ONLY ambient-INDEPENDENT directed
|
|
27
|
+
* (the `identity.name` / `identity.shortName` settings; the brand "open everywhere" / "oe" only when
|
|
28
|
+
* no name is configured) or the platform-resolved integration name (botName). This is the ONLY ambient-INDEPENDENT directed
|
|
29
29
|
* input Notion has, so — unlike Slack/GitHub/Linear, where a name-drop stays a judged input because a
|
|
30
30
|
* real @mention is available — here it is promoted to `direct`.
|
|
31
31
|
*
|