@open-everywhere/provider-notion 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/agent/channels/notion-api.ts +27 -5
  2. package/agent/channels/notion-engagement.ts +3 -2
  3. package/agent/channels/notion-event-reach.ts +36 -0
  4. package/agent/channels/notion-scope.ts +3 -0
  5. package/agent/channels/notion-supervisor.ts +3 -3
  6. package/agent/channels/notion-webhook-timing.ts +4 -2
  7. package/agent/channels/notion.ts +5 -1
  8. package/agent/docs-generated.ts +422 -3
  9. package/agent/register.ts +5 -0
  10. package/agent/surface-parity.ts +1 -0
  11. package/agent/tools/read_notion_comments.ts +20 -1
  12. package/agent/tools/read_notion_page.ts +2 -0
  13. package/dist/agent/channels/notion-api.d.ts +6 -23
  14. package/dist/agent/channels/notion-api.d.ts.map +1 -1
  15. package/dist/agent/channels/notion-api.js +30 -6
  16. package/dist/agent/channels/notion-api.js.map +1 -1
  17. package/dist/agent/channels/notion-engagement.d.ts +2 -1
  18. package/dist/agent/channels/notion-engagement.d.ts.map +1 -1
  19. package/dist/agent/channels/notion-engagement.js.map +1 -1
  20. package/dist/agent/channels/notion-event-reach.d.ts +8 -0
  21. package/dist/agent/channels/notion-event-reach.d.ts.map +1 -0
  22. package/dist/agent/channels/notion-event-reach.js +39 -0
  23. package/dist/agent/channels/notion-event-reach.js.map +1 -0
  24. package/dist/agent/channels/notion-scope.d.ts.map +1 -1
  25. package/dist/agent/channels/notion-scope.js +3 -1
  26. package/dist/agent/channels/notion-scope.js.map +1 -1
  27. package/dist/agent/channels/notion-supervisor.d.ts +0 -28
  28. package/dist/agent/channels/notion-supervisor.d.ts.map +1 -1
  29. package/dist/agent/channels/notion-supervisor.js +3 -3
  30. package/dist/agent/channels/notion-supervisor.js.map +1 -1
  31. package/dist/agent/channels/notion-webhook-timing.d.ts +3 -2
  32. package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -1
  33. package/dist/agent/channels/notion-webhook-timing.js.map +1 -1
  34. package/dist/agent/channels/notion.d.ts.map +1 -1
  35. package/dist/agent/channels/notion.js +5 -2
  36. package/dist/agent/channels/notion.js.map +1 -1
  37. package/dist/agent/docs-generated.d.ts.map +1 -1
  38. package/dist/agent/docs-generated.js +422 -3
  39. package/dist/agent/docs-generated.js.map +1 -1
  40. package/dist/agent/register.d.ts.map +1 -1
  41. package/dist/agent/register.js +5 -1
  42. package/dist/agent/register.js.map +1 -1
  43. package/dist/agent/surface-parity.d.ts.map +1 -1
  44. package/dist/agent/surface-parity.js +1 -0
  45. package/dist/agent/surface-parity.js.map +1 -1
  46. package/dist/agent/tools/read_notion_comments.d.ts.map +1 -1
  47. package/dist/agent/tools/read_notion_comments.js +21 -2
  48. package/dist/agent/tools/read_notion_comments.js.map +1 -1
  49. package/dist/agent/tools/read_notion_page.d.ts.map +1 -1
  50. package/dist/agent/tools/read_notion_page.js +2 -0
  51. package/dist/agent/tools/read_notion_page.js.map +1 -1
  52. package/package.json +6 -2
@@ -4,10 +4,429 @@
4
4
  // manual is compiled in at all (docs/ does not exist on disk in a hosted deployment).
5
5
  export const PROVIDER_DOCS = [
6
6
  {
7
- path: "docs/deploy/surfaces/notion.md",
8
7
  title: "Notion",
9
- 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…",
10
- 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- **On an addressed-only page, it needs the page to be in a memory domain, and that is the floor.**\n A name-drop answers on a page one of your domains names outright in `reach` or admits by a\n container entry, whether or not that domain has `ambientSupervisor` on. Where ambient is off, a\n page the integration can merely *see*, in no domain at all, does nothing. So this is one door —\n an addressed page can be addressed — not an open one. (Where ambient is on, which is the default,\n every comment on the page reaches the supervisor anyway — see below.)\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### On by default, off per **domain**\n\nThe whole ambient path is **on by default** (OPE-2409). The Notion doc's memory **domain** decides with `ambientSupervisor` — the same domain flag that governs Slack's ambient supervisor. Unset, it inherits the `org` domain's value, and an unset `org` means on. So every page the integration is connected to goes ambient, including a page in no explicit domain, which falls to `org`. A config that will not load fails closed to off.\n\nTo make a doc **addressed-only**, set `ambientSupervisor: false` on its domain, or on `org` for every domain that does not set `true` itself. An addressed-only doc 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. Under `org: { ambientSupervisor: false }`, a doc in **no** domain does nothing at all, name-drop included. The page's trust is still walked as below: an unvouched page keeps the fail-closed `external` binding whatever this flag says. 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** (the setting `identity.blanketTrust: \"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`), and upload [`brand/png/mark-square-1024.png`](../../../brand/png/mark-square-1024.png), the project mark, as its logo. Skip this when you set [`identity.name`](../configuration-reference.md) and chose your own icon: with no name configured the agent presents as the project, so the project mark is its default avatar. 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.\n# VOUCH the workspace so the shared doc-brain silo lights up (see above): also a SETTING, not a variable.\n# Set `identity.blanketTrust: \"internal\"` in agent/settings/settings.ts (was OE_TRUST until OPE-2411),\n# or vouch the one workspace in agent/identity/surfaces.ts.\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**Ambient is on by default** for the doc. To keep it addressed-only, set `ambientSupervisor: false` on its memory domain — see [config-as-code](../../how-it-works/config-as-code.md) / [Memory](../../capabilities/memory.md).\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 on unless a domain turns it off.** A connected doc is ambient unless its memory domain, or the `org` domain it inherits from, sets `ambientSupervisor: false`. If comments get no response, you do not 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 (`domain-refused` — a domain declares `ambientSupervisor: false` and this page inherits it — versus `no-domain` — no domain routes the page and the app has no `org` domain, or the domain set did not load, so the gate failed closed) and the line to change. 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 `identity.blanketTrust: \"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 `identity.blanketTrust: \"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",
8
+ path: "docs/deploy/surfaces/notion.md",
9
+ 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, keeps a standing seat on the page, and steps in only where it clearly adds something —…",
10
+ markdown: [
11
+ "# Notion",
12
+ "",
13
+ "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, keeps a standing seat on the page, 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.",
14
+ "",
15
+ "This 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).",
16
+ "",
17
+ "## How it works — an ambient-judged surface",
18
+ "",
19
+ "Every 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 the page's **standing seat** — the same ambient judge a Slack channel's standing seat uses — decides whether to engage:",
20
+ "",
21
+ "- **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.",
22
+ "- **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.",
23
+ "- **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.",
24
+ "- **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.",
25
+ "- **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 page's standing seat 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.",
26
+ "- **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.",
27
+ "- **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.",
28
+ "- **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.)",
29
+ "- **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)).",
30
+ "",
31
+ "**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.",
32
+ "",
33
+ "Two consequences worth knowing:",
34
+ "",
35
+ "- **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.",
36
+ "- **`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.",
37
+ "",
38
+ "**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:",
39
+ "",
40
+ "- **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.",
41
+ "- **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 memory.",
42
+ "",
43
+ "Both 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.",
44
+ "",
45
+ "Replies post back into the **same discussion thread** (`POST /v1/comments`).",
46
+ "",
47
+ "> 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.",
48
+ "",
49
+ "### Naming the agent engages the page",
50
+ "",
51
+ "Notion lets nobody @mention an internal integration, so **naming** the agent in a comment is how it",
52
+ "is addressed here. The agent answers to the Notion integration's own name, plus the configured",
53
+ "`identity.name` and `identity.shortName` settings — or the brand phrase and `oe` on a deployment that",
54
+ "configures no name. With a name configured, the brand phrase and `oe` no longer address the agent,",
55
+ "unless the Notion integration's own name is exactly one of them.",
56
+ "That name-drop also marks the **page** engaged: later plain comments on it — in any of its",
57
+ "discussions — wake the agent with no second name-drop, because the invitation was the comment",
58
+ "somebody already wrote.",
59
+ "",
60
+ "- **On an addressed-only page, it needs the page to be in a memory domain, and that is the floor.**",
61
+ " A name-drop answers on a page one of your domains names outright in `reach` or admits by a",
62
+ " container entry, whether or not that domain has `ambientSupervisor` on. Where ambient is off, a",
63
+ " page the integration can merely *see*, in no domain at all, does nothing. So this is one door —",
64
+ " an addressed page can be addressed — not an open one. (Where ambient is on, which is the default,",
65
+ " every comment on the page reaches the supervisor anyway — see below.)",
66
+ "- **The grain is the page, not the discussion.** One standing session serves the whole page, so a",
67
+ " stop asked for in one discussion is a stop for all of them.",
68
+ "- **Asking it to stop works, and it is durable.** Say it in words and the agent calls `stand_down`,",
69
+ " which stores the stop against the page. It then answers nothing there — including through the",
70
+ " ambient supervisor below — until somebody names it again. There is no timeout.",
71
+ "- **A page nobody ever named it on is untouched** by this. It wakes only through the ambient",
72
+ " supervisor below.",
73
+ "- **An author your `externalPolicy` excludes cannot engage a page**, and cannot lift a stop by",
74
+ " naming the agent — their comment is suppressed before the mark, so nothing is written.",
75
+ "",
76
+ "### On by default, off per **domain**",
77
+ "",
78
+ "The whole ambient path is **on by default** (OPE-2409). The Notion doc's memory **domain** decides with `ambientSupervisor` — the same domain flag that governs Slack's ambient supervisor. Unset, it inherits the `org` domain's value, and an unset `org` means on. So every page the integration is connected to goes ambient, including a page in no explicit domain, which falls to `org`. A config that will not load fails closed to off.",
79
+ "",
80
+ "To make a doc **addressed-only**, set `ambientSupervisor: false` on its domain, or on `org` for every domain that does not set `true` itself. An addressed-only doc 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. Under `org: { ambientSupervisor: false }`, a doc in **no** domain does nothing at all, name-drop included. The page's trust is still walked as below: an unvouched page keeps the fail-closed `external` binding whatever this flag says. A domain can compose docs **across platforms** (Slack channels + GitHub repos + Linear teams + Notion docs share one flag, and one silo when the domain declares `memory`), 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)).",
81
+ "",
82
+ "**The flag decides what wakes the page's standing seat, nothing else.** It does not decide how many pages share one seat (`mode`, `windows`) or which memory the seat reads (`memory`). A page in no explicit domain has its own solo seat, even with `ambientSupervisor` on at `org`. See [Wake, seat, memory: three independent axes](../configuration-reference.md#wake-seat-memory-three-independent-axes).",
83
+ "",
84
+ "## Structural changes can trigger routines — comments cannot",
85
+ "",
86
+ "One Notion webhook subscription now feeds **two** things, and they are split cleanly by event type:",
87
+ "",
88
+ "- **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.",
89
+ "- **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.",
90
+ "",
91
+ "No single delivery does both. Nothing new to configure: the subscription and its signing secret are the ones you set up in Step 2.",
92
+ "",
93
+ "A 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.",
94
+ "",
95
+ "### 🔴 What authorizes a Notion trigger — and what does not",
96
+ "",
97
+ "A 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.",
98
+ "",
99
+ "**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.",
100
+ "",
101
+ "That 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)).",
102
+ "",
103
+ "**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.",
104
+ "",
105
+ "- **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.",
106
+ "- **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.",
107
+ "",
108
+ "Either 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.",
109
+ "",
110
+ "The 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.",
111
+ "",
112
+ "`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.",
113
+ "",
114
+ "## How the silo model maps",
115
+ "",
116
+ "Open Everywhere's memory is [opt-in domains and silos](../../capabilities/memory.md), not an",
117
+ "automatic per-workspace tier stack. A Notion comment lives on a **page** in a workspace,",
118
+ "inside a **discussion**. The page is the persistent container that hosts many discussions",
119
+ "over time, so:",
120
+ "",
121
+ "| Open Everywhere concept | Notion thing |",
122
+ "| --- | --- |",
123
+ "| `org` (tenant) | your deployment (`deploymentIdentity.org` in `agent/identity/surfaces.ts`, one org per deploy today) |",
124
+ "| `workspace` (identity key) | the Notion **workspace** (`workspace_id`) |",
125
+ "| `surface` (partition key) | the **page** the comment lives on (`page_id`) |",
126
+ "| invocation + post-back anchor | the **discussion** thread (`discussion_id`) |",
127
+ "| `mode` | `channel` (Notion has no 1:1 DM analog) |",
128
+ "| `visibility` | `private`, fail-closed (see below) |",
129
+ "",
130
+ "**Surface = page, not discussion.** Every discussion on a page shares one memory silo, so the page's memory carries context across all of a page's threads instead of fragmenting per thread.",
131
+ "",
132
+ "### Shared page memory needs a workspace vouch",
133
+ "",
134
+ "Visibility **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** (the setting `identity.blanketTrust: \"internal\"`, or a per-workspace vouch in `surfaces.ts`). Ambient does **not** relax this — reading more comments never grants more trust.",
135
+ "",
136
+ "Consequence: unvouched, the agent still judges and replies, but **without a shared page-memory silo**. The full \"remembers every prior discussion on this doc\" experience lights up only once you vouch the workspace — one deliberate operator action.",
137
+ "",
138
+ "## Prerequisites",
139
+ "",
140
+ "- **Admin** on the target Notion workspace (to create an internal integration and subscribe a webhook).",
141
+ "- A reachable Open Everywhere instance (public HTTPS, or local with a tunnel). The Notion webhook route is **`/eve/v1/notion`**.",
142
+ "",
143
+ "## Step 1 — Create an internal integration",
144
+ "",
145
+ "1. Go to **notion.so/my-integrations → New integration** (internal). Name it (e.g. `Open Everywhere`), and upload [`brand/png/mark-square-1024.png`](../../../brand/png/mark-square-1024.png), the project mark, as its logo. Skip this when you set [`identity.name`](../configuration-reference.md) and chose your own icon: with no name configured the agent presents as the project, so the project mark is its default avatar. 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.",
146
+ "2. 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.",
147
+ "3. **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.",
148
+ "",
149
+ "> **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.",
150
+ "",
151
+ "## Step 2 — Subscribe the webhook + complete the handshake",
152
+ "",
153
+ "1. In the integration's **Webhooks** tab, add a subscription with the URL:",
154
+ "",
155
+ " ```",
156
+ " https://<your-deploy-url>/eve/v1/notion",
157
+ " ```",
158
+ "",
159
+ " Subscribe to the **`comment.created`** event.",
160
+ "2. 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**:",
161
+ "",
162
+ " ```",
163
+ " [open-everywhere:notion] handshake secret for Notion webhook (verification_token) received and",
164
+ " STORED — NOT logged (sha256 fingerprint 1a2b3c4d; confirm your env var with:",
165
+ " printf %s \"$SECRET\" | sha256sum | cut -c1-8). Read it ONCE here, within 15 minutes — the link is",
166
+ " single-use and whoever opens it first gets the secret, so treat it as the secret itself:",
167
+ " https://<your-deploy-url>/connect/v1/reveal?t=…",
168
+ " ```",
169
+ "",
170
+ " 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.",
171
+ "",
172
+ " > **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.",
173
+ "",
174
+ " Confirm afterwards that what you pasted is what Notion sent, without exposing it:",
175
+ "",
176
+ " ```bash",
177
+ " printf %s \"$NOTION_WEBHOOK_SECRET\" | sha256sum | cut -c1-8 # must equal the logged fingerprint",
178
+ " ```",
179
+ "",
180
+ " **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.",
181
+ "",
182
+ "### Before you resume a paused subscription",
183
+ "",
184
+ "A verified delivery is acknowledged `200` only after its projected, content-minimal event has been",
185
+ "committed to the operational store. Processing starts immediately, while the mounted",
186
+ "`notion-ingress` schedule retries due work every minute after a process exit or failed self-call.",
187
+ "Production therefore needs a **persistent operational store**, `OE_CREDENTIAL_ENCRYPTION_KEY`, and",
188
+ "a resolvable deployment URL (`OE_PUBLIC_BASE_URL`, or Vercel's injected deployment URL). The",
189
+ "internal drain's HMAC key is minted into the existing credential vault; do not create or distribute",
190
+ "another secret. On Vercel, a fresh production deploy registers the schedule; run",
191
+ "`npm run deploy:verify` after deployment so its cron-registration check can compare the deployed",
192
+ "cron set with the build. On a self-host, run the compiled app through `eve start`, which starts the",
193
+ "mounted schedule; a bespoke HTTP-only route host does not run schedules.",
194
+ "",
195
+ "If you paused the Notion subscription for an upgrade, keep it paused until the new deployment boots,",
196
+ "the operational store is persistent and reachable, the self-call URL resolves to that deployment,",
197
+ "and the cron-registration check passes. Then resume the existing subscription; do not recreate it",
198
+ "or rotate its signing secret merely to resume delivery.",
199
+ "",
200
+ "Delivery is **at least once**. Pending payloads have no time-based expiry, so an extended outage",
201
+ "does not silently delete accepted work. Completion erases the payload and retains only a dedupe",
202
+ "tombstone for 24 hours. A crash after Eve accepted a comment but before completion can produce a",
203
+ "duplicate turn after the five-minute lease expires; the comment-level guard suppresses ordinary",
204
+ "redelivery for ten minutes. A refusal before dispatch removes the pending row, so a later vendor",
205
+ "redelivery can be reconsidered after configuration or credentials are repaired.",
206
+ "",
207
+ "## Step 3 — Configure the environment",
208
+ "",
209
+ "Both 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:",
210
+ "",
211
+ "```bash",
212
+ "NOTION_BOT_USER_ID=… # the ORG-DEFAULT integration's own user id — self-reply loop guard.",
213
+ " # Auto-resolved via GET /v1/users/me; set it to skip that call (or in",
214
+ " # tests). It pins the `channel-key` integration ONLY: a domain that binds",
215
+ " # its own integration always resolves that one live, because two Notion",
216
+ " # integrations never share a bot user.",
217
+ "NOTION_BOT_NAME=… # the integration's display name — the name-drop judge input. Auto-resolved too.",
218
+ "# VOUCH the workspace so the shared page-memory silo lights up (see above): also a SETTING, not a variable.",
219
+ "# Set `identity.blanketTrust: \"internal\"` in agent/settings/settings.ts (was OE_TRUST until OPE-2411),",
220
+ "# or vouch the one workspace in agent/identity/surfaces.ts.",
221
+ "# DEV ONLY, and it is a SETTING not a variable: turn on `webhooks.allowUnverifiedNotion`",
222
+ "# in agent/settings/settings.ts to accept unsigned deliveries for local smoke tests.",
223
+ "```",
224
+ "",
225
+ "**Ambient is on by default** for the doc. To keep it addressed-only, set `ambientSupervisor: false` on its memory domain — see [config-as-code](../../how-it-works/config-as-code.md) / [Memory](../../capabilities/memory.md).",
226
+ "",
227
+ "## Step 4 (optional) — Notion as a **work surface**",
228
+ "",
229
+ "Steps 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.",
230
+ "",
231
+ "To let a domain **work** on Notion, declare its Notion work credential:",
232
+ "",
233
+ "```ts",
234
+ "defineDomain({",
235
+ " name: \"payments\",",
236
+ " reach: [\"slack/T…/C…\", \"notion/<workspace>/<page>\"],",
237
+ " credentials: [",
238
+ " // The GRANT. `credential: \"team-key\"` names Notion's WORK role; `use` names the",
239
+ " // credential id this domain's Notion work calls run under.",
240
+ " { connector: \"notion\", credential: \"team-key\", use: \"payments-notion-key\" },",
241
+ " ],",
242
+ "});",
243
+ "```",
244
+ "",
245
+ "What the declaration turns on, for **every** surface in that domain:",
246
+ "",
247
+ "| tool | what it does |",
248
+ "|---|---|",
249
+ "| `notion_search` | Find pages and databases by title and text. Returns ids + titles, never page content. Mounted **only** under this grant. |",
250
+ "| `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. |",
251
+ "",
252
+ "Four rules govern it:",
253
+ "",
254
+ "- **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.",
255
+ "- **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\".",
256
+ "- **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.",
257
+ "- **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.",
258
+ "- **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.",
259
+ "",
260
+ "Work 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).",
261
+ "",
262
+ "**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.",
263
+ "",
264
+ "## Step 4 — One integration per domain (optional)",
265
+ "",
266
+ "By default one Notion integration serves every domain. That is fine for most deployments, and it is",
267
+ "the only setup the steps above need. It also means the credential is **identical for every domain**,",
268
+ "so the domain boundary is OE's config alone.",
269
+ "",
270
+ "Notion supports **many** integrations, and each one only sees the pages a human shared with it. Bind",
271
+ "a domain to its own integration and Notion itself enforces the boundary for that domain's pages.",
272
+ "",
273
+ "**1. Create a second integration** (Step 1), share **only** that domain's pages with it, and store",
274
+ "its secret under its own name:",
275
+ "",
276
+ "```bash",
277
+ "oe secret set db:notion:payments-channel-key # value on stdin",
278
+ "```",
279
+ "",
280
+ "**2. Declare it as a credential** on the notion catalogue in `agent/access/connector-credentials.ts`, beside the",
281
+ "default `channel-key`:",
282
+ "",
283
+ "```ts",
284
+ "{ id: \"payments-channel-key\", type: \"api-key\", secretRef: \"db:notion:payments-channel-key\", broker: \"db\", intake: false },",
285
+ "```",
286
+ "",
287
+ "**3. Subscribe its own webhook** to the SAME URL (`/eve/v1/notion`) and complete the handshake",
288
+ "(Step 2). Each integration issues its **own** `verification_token`, so store this one under its own",
289
+ "name too:",
290
+ "",
291
+ "```bash",
292
+ "oe secret set NOTION_WEBHOOK_SECRET_PAYMENTS # value on stdin",
293
+ "```",
294
+ "",
295
+ "**4. Bind the domain** in your `domains` config:",
296
+ "",
297
+ "```ts",
298
+ "defineDomain({",
299
+ " name: \"payments\",",
300
+ " reach: [\"notion/<workspace>/<page>\"],",
301
+ " ambientSupervisor: true,",
302
+ " credentials: [",
303
+ " { connector: \"notion\", credential: \"channel-key\", use: \"payments-channel-key\", inboundSecret: \"NOTION_WEBHOOK_SECRET_PAYMENTS\" },",
304
+ " ],",
305
+ "}),",
306
+ "```",
307
+ "",
308
+ "**What then happens.** Reads and posts an **agent-initiated** turn makes for a page in `payments` —",
309
+ "no delivery woke it, e.g. a routine's proactive post — run under `payments-channel-key`. Every other",
310
+ "domain keeps using `channel-key`. A **delivery-woken** session's `read_notion_page` /",
311
+ "`read_notion_comments` calls follow the rule two paragraphs down instead: the credential that",
312
+ "verified the delivery, not the domain's binding. The `memory` role is unaffected either way — see",
313
+ "the note below.",
314
+ "",
315
+ "**An inbound delivery is decided by its signature, not by the page it names.** Every configured",
316
+ "integration's secret is tried, and the one that verifies identifies the sender — so both",
317
+ "subscriptions work on one endpoint with no routing table. That verifying integration then serves",
318
+ "the whole delivery: the two-step fetch, and the reply, which posts under the same credential that",
319
+ "fetched. Nothing in the payload steers this, deliberately — a delivery's page id is unauthenticated,",
320
+ "so if the page's binding outranked the signature, anyone holding one integration's",
321
+ "`verification_token` could name another domain's page and have OE act with that domain's key.",
322
+ "",
323
+ "**The page the session lands on comes from the same place.** OE does not read it off the payload",
324
+ "either. It reads the comment under the verifying integration's own key and takes the page from what",
325
+ "Notion answers — the comment's page parent, or, for an inline comment, the page that owns the",
326
+ "commented block. That page decides the session's domain. `read_notion_page` / `read_notion_comments`,",
327
+ "though, run under the **verifying credential itself** — the one that fetched and will reply — never a",
328
+ "fresh lookup off that domain's binding. One rule for the whole delivery: the credential that verified",
329
+ "serves the fetch, those two read tools, and the reply alike. A delivery whose page will not resolve",
330
+ "that way is acknowledged and dropped; the payload's page id is never the fallback.",
331
+ "",
332
+ "**The `memory` role does not follow that rule.** A domain's Notion memory silo (its `memory`",
333
+ "credential, distinct from `channel-key` — see [Memory backings bind separately](#step-4--one-integration-per-domain-optional)",
334
+ "below) still resolves off the **domain binding**, whichever integration verified the delivery. So a",
335
+ "double-shared page's session reads its doc body and comments under the narrower, verifying key, but",
336
+ "still draws its shared memory — if any — under the domain's own memory integration.",
337
+ "",
338
+ "In the configuration this step describes, the two answers are the same one: unshare the payments",
339
+ "pages from the default integration and the default subscription never sees them. They differ only",
340
+ "while a page is shared with both integrations — and then each subscription's delivery correctly runs",
341
+ "under its own integration, which is what keeps both working.",
342
+ "",
343
+ "### The rules worth knowing before you rely on it",
344
+ "",
345
+ "- **A domain with no binding uses the org default.** That is the fallback, not a misconfiguration.",
346
+ " You never have to bind every domain to bind one.",
347
+ "- **A binding is never downgraded.** If `use` names a credential the provider does not declare, the",
348
+ " deployment **refuses to boot** and names the domain. If the credential is declared but its secret",
349
+ " is unset, that domain's Notion calls fail closed — they do **not** retry under the org key. The",
350
+ " self-reply loop guard treats such an integration as unable to post **from this deployment**, so it",
351
+ " does **not** make the rest of the deployment deaf; an integration whose key WORKS but whose",
352
+ " `GET /v1/users/me` fails does — see the loop-guard bullet under [Gotchas](#gotchas).",
353
+ "- **The vendor fence is only as good as your share lists.** Binding `payments` changes which key OE",
354
+ " calls with; it cannot un-share a page. If the page is also shared with the default integration,",
355
+ " that key can still read it — and the default subscription will also deliver that page's comments,",
356
+ " which OE then serves under the default key, because that is the integration that proved it sent",
357
+ " them. A comment on it wakes a session that surfaces on that page, so the session sits in the",
358
+ " `payments` domain — but its `read_notion_page` / `read_notion_comments` calls run under the",
359
+ " **default** key, because that is the credential that verified this particular delivery, not the",
360
+ " domain's own `payments-channel-key`. So the cost of a double-shared page, for those two tools,",
361
+ " stays what Notion's own share list says the default integration can see. The domain's `memory`",
362
+ " silo is unaffected by which integration verified the delivery — it still pulls under whatever",
363
+ " credential the domain's own `memory` role names (a separate binding from `channel-key`), so a",
364
+ " double-shared page narrows the read tools' reach but not the memory silo's. Unshare it anyway:",
365
+ " while it stays shared, the weaker default key is a second door onto that one page.",
366
+ "- **`omit inboundSecret`** when several domains sit behind ONE subscription — they then share the",
367
+ " deployment's default signing secret. Name your own the moment you create a second subscription.",
368
+ "- **Each integration has its own bot user**, so the self-reply loop guard resolves and caches one",
369
+ " identity per integration. 🔴 **`NOTION_BOT_USER_ID` pins the ORG-DEFAULT integration only, and that",
370
+ " is deliberate.** Two Notion integrations never share a bot user, so one pinned id cannot stand for",
371
+ " both; OE resolves every other integration live and the variable has no effect on them. The guard",
372
+ " compares a comment against **every**",
373
+ " configured integration's bot id, so two integrations that can both see one page do not answer each",
374
+ " other: a reply one posts is recognised as the deployment's own words on the other's subscription.",
375
+ " The cost is one `GET /v1/users/me` per integration, on the first delivery each process handles.",
376
+ "- 🔴 **One integration whose bot id will not resolve makes the deployment deaf to every Notion",
377
+ " comment Notion does not mark as person-authored — addressed ones included.** The guard fails",
378
+ " closed: with an id missing it cannot tell that integration's own reply from a person's, because",
379
+ " Notion omits `type` on a comment's author. It therefore drops every comment Notion does not",
380
+ " explicitly mark as person-authored, until the id resolves. 🔴 **That is not only ambient chatter.**",
381
+ " The check runs before the addressed arms, so during such an episode a name-drop that asks the agent",
382
+ " directly, and an `oe link <code>` identity claim, are dropped too. The failure is",
383
+ " cached for the life of the process, so a credential you fix takes effect on the next deploy or",
384
+ " restart. **Check every integration's token after you add one** — an integration listed with a",
385
+ " signing secret but no working token is the shape that trips this.",
386
+ "- **Memory backings bind separately, on their own role.** The `notion` memory projection has its own",
387
+ " credential role, `memory`, distinct from `channel-key`. Override it the same way:",
388
+ " `credentials: [{ connector: \"notion\", credential: \"memory\", use: \"payments-memory-key\" }]`. A",
389
+ " domain may bind one role, both, or neither — an operator often shares a domain's MEMORY pages with",
390
+ " one integration and its WORK pages with another. Unbound domains read their memory under the org",
391
+ " default (`db:notion:memory`), as before.",
392
+ "- **A shared silo reads under EACH lister's own credential in a session.** A silo two domains both",
393
+ " list is read, in a session, under the credential of whichever domain's `memory` list named it —",
394
+ " so a bound domain pulls the shared subtree under its OWN integration. Share the silo's pages with",
395
+ " every bound lister's integration, or its memory reads empty for that domain. Only the silo-first",
396
+ " readers (the control plane, the tidy schedule, the purge worker) fall back to the org default for",
397
+ " a silo with two owners.",
398
+ "",
399
+ "## Gotchas",
400
+ "",
401
+ "- **Ambient is on unless a domain turns it off.** A connected doc is ambient unless its memory domain, or the `org` domain it inherits from, sets `ambientSupervisor: false`. If comments get no response, you do not 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 (`domain-refused` — a domain declares `ambientSupervisor: false` and this page inherits it — versus `no-domain` — no domain routes the page and the app has no `org` domain, or the domain set did not load, so the gate failed closed) and the line to change. Query it with `GET /control/v1/audit?since=<iso>&actions=notion.ambient_skipped`.",
402
+ "- **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.",
403
+ "- **Shared page memory needs a workspace vouch.** Unvouched (the default), the agent judges + replies but has no shared page-memory silo — see [the page-memory section](#shared-page-memory-needs-a-workspace-vouch). Set `identity.blanketTrust: \"internal\"` (or vouch the workspace) to unlock it.",
404
+ "- **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.",
405
+ " - *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.",
406
+ " - *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.",
407
+ "- **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 shared page memory — only completes when the commenting account resolves **internal**: vouch the workspace (or set `identity.blanketTrust: \"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.",
408
+ "- **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.",
409
+ "- **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.",
410
+ "- **`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.",
411
+ "- **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.",
412
+ "- **No ticket-action verbs (`set_state`/`assign`/`add_label`).** Unlike Linear/GitHub, a Notion",
413
+ " page has no typed status/label/assignee schema OE can map to those verbs generically (a",
414
+ " database's Status property shape is per-database, not a platform convention) — see",
415
+ " [Source control § Ticket actions](../../capabilities/source-control.md#ticket-actions-set_state--assign--add_label).",
416
+ " Called against a Notion ref, all three refuse cleanly (\"not supported on notion\") rather than",
417
+ " guess at a mapping. A future Notion-database-aware mapping is possible but not built.",
418
+ "- **No `create_ticket` either**, for the same reason: filing a \"ticket\" into Notion means creating",
419
+ " a database row, and which property is the title (and which is status) is per-database, not a",
420
+ " platform convention. Notion registers no create adapter, so `create_ticket` refuses on it",
421
+ " **naming the surfaces that do work** (GitHub, Linear) instead of inventing a row shape — see",
422
+ " [Source control § Filing new work](../../capabilities/source-control.md#filing-new-work-create_ticket).",
423
+ "- **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.",
424
+ "",
425
+ "## Status",
426
+ "",
427
+ "Built, 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.",
428
+ "",
429
+ ].join("\n"),
11
430
  },
12
431
  ];
13
432
  //# sourceMappingURL=docs-generated.js.map