@open-everywhere/provider-notion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +48 -0
  4. package/agent/channels/notion-acting-identity.ts +88 -0
  5. package/agent/channels/notion-ancestry-trust.ts +458 -0
  6. package/agent/channels/notion-api.ts +1290 -0
  7. package/agent/channels/notion-bot-user.ts +250 -0
  8. package/agent/channels/notion-catch-up.ts +339 -0
  9. package/agent/channels/notion-credentials.ts +417 -0
  10. package/agent/channels/notion-engagement.ts +372 -0
  11. package/agent/channels/notion-event-normalizer.ts +587 -0
  12. package/agent/channels/notion-identity.ts +271 -0
  13. package/agent/channels/notion-ingress-drain.ts +289 -0
  14. package/agent/channels/notion-ingress.ts +182 -0
  15. package/agent/channels/notion-message-ops.ts +140 -0
  16. package/agent/channels/notion-origin-codec.ts +38 -0
  17. package/agent/channels/notion-post-arm.ts +189 -0
  18. package/agent/channels/notion-question-return.ts +223 -0
  19. package/agent/channels/notion-read.ts +283 -0
  20. package/agent/channels/notion-scope.ts +560 -0
  21. package/agent/channels/notion-stop.ts +169 -0
  22. package/agent/channels/notion-supervisor.ts +856 -0
  23. package/agent/channels/notion-webhook-timing.ts +131 -0
  24. package/agent/channels/notion-webhook-verifier.ts +55 -0
  25. package/agent/channels/notion-webhook.ts +963 -0
  26. package/agent/channels/notion-work-search.ts +74 -0
  27. package/agent/channels/notion-work.ts +144 -0
  28. package/agent/channels/notion.ts +1242 -0
  29. package/agent/contributions.ts +39 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/memory/blocks.ts +295 -0
  32. package/agent/memory/memory.ts +622 -0
  33. package/agent/provider-events.ts +198 -0
  34. package/agent/register.ts +144 -0
  35. package/agent/schedules/notion-ingress.ts +20 -0
  36. package/agent/surface-parity.ts +105 -0
  37. package/agent/tools/notion_search.ts +110 -0
  38. package/agent/tools/read_notion_comments.ts +250 -0
  39. package/agent/tools/read_notion_page.ts +351 -0
  40. package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
  41. package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
  42. package/dist/agent/channels/notion-acting-identity.js +85 -0
  43. package/dist/agent/channels/notion-acting-identity.js.map +1 -0
  44. package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
  45. package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
  46. package/dist/agent/channels/notion-ancestry-trust.js +331 -0
  47. package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
  48. package/dist/agent/channels/notion-api.d.ts +655 -0
  49. package/dist/agent/channels/notion-api.d.ts.map +1 -0
  50. package/dist/agent/channels/notion-api.js +938 -0
  51. package/dist/agent/channels/notion-api.js.map +1 -0
  52. package/dist/agent/channels/notion-bot-user.d.ts +107 -0
  53. package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
  54. package/dist/agent/channels/notion-bot-user.js +192 -0
  55. package/dist/agent/channels/notion-bot-user.js.map +1 -0
  56. package/dist/agent/channels/notion-catch-up.d.ts +64 -0
  57. package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
  58. package/dist/agent/channels/notion-catch-up.js +289 -0
  59. package/dist/agent/channels/notion-catch-up.js.map +1 -0
  60. package/dist/agent/channels/notion-credentials.d.ts +210 -0
  61. package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
  62. package/dist/agent/channels/notion-credentials.js +360 -0
  63. package/dist/agent/channels/notion-credentials.js.map +1 -0
  64. package/dist/agent/channels/notion-engagement.d.ts +219 -0
  65. package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
  66. package/dist/agent/channels/notion-engagement.js +283 -0
  67. package/dist/agent/channels/notion-engagement.js.map +1 -0
  68. package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
  69. package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
  70. package/dist/agent/channels/notion-event-normalizer.js +542 -0
  71. package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
  72. package/dist/agent/channels/notion-identity.d.ts +119 -0
  73. package/dist/agent/channels/notion-identity.d.ts.map +1 -0
  74. package/dist/agent/channels/notion-identity.js +205 -0
  75. package/dist/agent/channels/notion-identity.js.map +1 -0
  76. package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
  77. package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
  78. package/dist/agent/channels/notion-ingress-drain.js +185 -0
  79. package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
  80. package/dist/agent/channels/notion-ingress.d.ts +64 -0
  81. package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
  82. package/dist/agent/channels/notion-ingress.js +130 -0
  83. package/dist/agent/channels/notion-ingress.js.map +1 -0
  84. package/dist/agent/channels/notion-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/notion-message-ops.js +126 -0
  87. package/dist/agent/channels/notion-message-ops.js.map +1 -0
  88. package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
  89. package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/notion-origin-codec.js +32 -0
  91. package/dist/agent/channels/notion-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/notion-post-arm.d.ts +3 -0
  93. package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/notion-post-arm.js +185 -0
  95. package/dist/agent/channels/notion-post-arm.js.map +1 -0
  96. package/dist/agent/channels/notion-question-return.d.ts +77 -0
  97. package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
  98. package/dist/agent/channels/notion-question-return.js +183 -0
  99. package/dist/agent/channels/notion-question-return.js.map +1 -0
  100. package/dist/agent/channels/notion-read.d.ts +62 -0
  101. package/dist/agent/channels/notion-read.d.ts.map +1 -0
  102. package/dist/agent/channels/notion-read.js +257 -0
  103. package/dist/agent/channels/notion-read.js.map +1 -0
  104. package/dist/agent/channels/notion-scope.d.ts +188 -0
  105. package/dist/agent/channels/notion-scope.d.ts.map +1 -0
  106. package/dist/agent/channels/notion-scope.js +433 -0
  107. package/dist/agent/channels/notion-scope.js.map +1 -0
  108. package/dist/agent/channels/notion-stop.d.ts +48 -0
  109. package/dist/agent/channels/notion-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/notion-stop.js +141 -0
  111. package/dist/agent/channels/notion-stop.js.map +1 -0
  112. package/dist/agent/channels/notion-supervisor.d.ts +462 -0
  113. package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/notion-supervisor.js +482 -0
  115. package/dist/agent/channels/notion-supervisor.js.map +1 -0
  116. package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
  117. package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
  118. package/dist/agent/channels/notion-webhook-timing.js +99 -0
  119. package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
  120. package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
  121. package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
  122. package/dist/agent/channels/notion-webhook-verifier.js +53 -0
  123. package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
  124. package/dist/agent/channels/notion-webhook.d.ts +242 -0
  125. package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
  126. package/dist/agent/channels/notion-webhook.js +675 -0
  127. package/dist/agent/channels/notion-webhook.js.map +1 -0
  128. package/dist/agent/channels/notion-work-search.d.ts +9 -0
  129. package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
  130. package/dist/agent/channels/notion-work-search.js +69 -0
  131. package/dist/agent/channels/notion-work-search.js.map +1 -0
  132. package/dist/agent/channels/notion-work.d.ts +68 -0
  133. package/dist/agent/channels/notion-work.d.ts.map +1 -0
  134. package/dist/agent/channels/notion-work.js +126 -0
  135. package/dist/agent/channels/notion-work.js.map +1 -0
  136. package/dist/agent/channels/notion.d.ts +95 -0
  137. package/dist/agent/channels/notion.d.ts.map +1 -0
  138. package/dist/agent/channels/notion.js +1081 -0
  139. package/dist/agent/channels/notion.js.map +1 -0
  140. package/dist/agent/contributions.d.ts +32 -0
  141. package/dist/agent/contributions.d.ts.map +1 -0
  142. package/dist/agent/contributions.js +37 -0
  143. package/dist/agent/contributions.js.map +1 -0
  144. package/dist/agent/docs-generated.d.ts +3 -0
  145. package/dist/agent/docs-generated.d.ts.map +1 -0
  146. package/dist/agent/docs-generated.js +13 -0
  147. package/dist/agent/docs-generated.js.map +1 -0
  148. package/dist/agent/memory/blocks.d.ts +58 -0
  149. package/dist/agent/memory/blocks.d.ts.map +1 -0
  150. package/dist/agent/memory/blocks.js +264 -0
  151. package/dist/agent/memory/blocks.js.map +1 -0
  152. package/dist/agent/memory/memory.d.ts +157 -0
  153. package/dist/agent/memory/memory.d.ts.map +1 -0
  154. package/dist/agent/memory/memory.js +471 -0
  155. package/dist/agent/memory/memory.js.map +1 -0
  156. package/dist/agent/provider-events.d.ts +3 -0
  157. package/dist/agent/provider-events.d.ts.map +1 -0
  158. package/dist/agent/provider-events.js +196 -0
  159. package/dist/agent/provider-events.js.map +1 -0
  160. package/dist/agent/register.d.ts +5 -0
  161. package/dist/agent/register.d.ts.map +1 -0
  162. package/dist/agent/register.js +128 -0
  163. package/dist/agent/register.js.map +1 -0
  164. package/dist/agent/schedules/notion-ingress.d.ts +6 -0
  165. package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
  166. package/dist/agent/schedules/notion-ingress.js +16 -0
  167. package/dist/agent/schedules/notion-ingress.js.map +1 -0
  168. package/dist/agent/surface-parity.d.ts +3 -0
  169. package/dist/agent/surface-parity.d.ts.map +1 -0
  170. package/dist/agent/surface-parity.js +99 -0
  171. package/dist/agent/surface-parity.js.map +1 -0
  172. package/dist/agent/tools/notion_search.d.ts +56 -0
  173. package/dist/agent/tools/notion_search.d.ts.map +1 -0
  174. package/dist/agent/tools/notion_search.js +100 -0
  175. package/dist/agent/tools/notion_search.js.map +1 -0
  176. package/dist/agent/tools/read_notion_comments.d.ts +57 -0
  177. package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
  178. package/dist/agent/tools/read_notion_comments.js +236 -0
  179. package/dist/agent/tools/read_notion_comments.js.map +1 -0
  180. package/dist/agent/tools/read_notion_page.d.ts +53 -0
  181. package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
  182. package/dist/agent/tools/read_notion_page.js +327 -0
  183. package/dist/agent/tools/read_notion_page.js.map +1 -0
  184. package/package.json +231 -0
  185. package/tsconfig.build.json +22 -0
  186. package/tsconfig.json +15 -0
@@ -0,0 +1,372 @@
1
+ // THE NOTION AMBIENT ENGAGEMENT SEAM (chat-surface contract ยง2.5) โ€” the Notion wiring over core's
2
+ // pure engagement policy (`packages/core/agent/channels/engagement.ts`). It gives Notion the middle
3
+ // posture Slack, Discord and Teams already have: addressing the app marks the PAGE ENGAGED, a later
4
+ // PLAIN comment on that page wakes the agent, and `stand_down` stores the stop.
5
+ //
6
+ // THE ANCHOR is `notionConversationKey(workspaceId, pageId)` โ€” `<workspace>/<page>`, the same string
7
+ // the mapper publishes as `anchorChannelId` for the stop registry. The doc session is PAGE-scoped
8
+ // (`notionSupervisorToken` keys the standing session by page, aware of all the page's discussions),
9
+ // so the page is the conversation a person can address a stop to, and a per-discussion mark would
10
+ // leave the same session answering the discussion next door.
11
+ //
12
+ // ๐Ÿ”ด THE ADDRESS IS A NAME-DROP, NOT AN @MENTION, AND THAT IS A VENDOR FACT. Notion lets no user
13
+ // @mention an internal integration at all, so `classifyNotionComment` promotes a name-drop (or the
14
+ // structurally-unreachable structured mention) to `direct` โ€” that IS Notion's addressed leg, and it
15
+ // is therefore what enrols a page here. `notionNameDrop` owns the matching, including its
16
+ // word-boundary guards; this module never looks at comment text.
17
+
18
+ import {
19
+ getOperationalStore,
20
+ getThreadEngagement,
21
+ markThreadEngaged,
22
+ resolvePartition,
23
+ shouldDeliverReply,
24
+ type EngagementRow,
25
+ type Partition,
26
+ } from "@open-everywhere/core/agent/adapter-api.js";
27
+ import { buildNotionAuth, notionConversationKey } from "./notion-identity.js";
28
+ // TYPE ONLY โ€” it erases, so the "this module imports only notion-identity.ts and core" note above
29
+ // `markNotionEngaged`'s use in notion-supervisor.ts still holds and no cycle is added.
30
+ import type { NotionVia } from "./notion-scope.js";
31
+
32
+ /** The facts one Notion delivery contributes to the seam โ€” everything the anchor and the partition
33
+ * need, and nothing about the comment's text. */
34
+ export interface NotionSeamFacts {
35
+ readonly workspaceId: string | undefined;
36
+ readonly pageId: string | undefined;
37
+ /** The acting Notion user, so the partition the seam keys is built from the delivery's identity. */
38
+ readonly principalId?: string;
39
+ /** The integration this delivery verified under โ€” carried for binding parity with the wake. */
40
+ readonly credentialId?: string;
41
+ }
42
+
43
+ /** The engagement-registry anchor for one Notion delivery, or `undefined` when either id is missing.
44
+ *
45
+ * ๐Ÿ”ด IT IS THE ONE PREDICATE THE MAPPER AND THE SEAM SHARE. `bindNotion` publishes exactly this as
46
+ * `engagementKey`, so `stand_down`'s mark and this seam's read address the same row. */
47
+ export function notionEngagementAnchor(f: NotionSeamFacts): string | undefined {
48
+ return notionConversationKey(f.workspaceId, f.pageId);
49
+ }
50
+
51
+ /** The partition one page's engagement row lives in โ€” `notion/<workspace>/<page>`, which is what
52
+ * `resolvePartition` gives a bound Notion session and therefore what `stand_down` writes under. It
53
+ * is built through the same `buildNotionAuth` the wake dispatches, so the seam can never key a row
54
+ * the turn would not. Null on an unresolvable identity โ€” the caller then keeps today's path. */
55
+ export function notionSeamPartition(f: NotionSeamFacts): Partition | null {
56
+ if (!f.workspaceId || !f.pageId) return null;
57
+ try {
58
+ const auth = buildNotionAuth({
59
+ workspaceId: f.workspaceId,
60
+ pageId: f.pageId,
61
+ ...(f.principalId ? { principalId: f.principalId } : {}),
62
+ ...(f.credentialId ? { credentialId: f.credentialId } : {}),
63
+ });
64
+ return resolvePartition({ channel: { kind: "notion" }, session: { auth: { current: auth } } });
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * What the seam says about one comment the classifier did NOT call `direct`. Three outcomes, each
72
+ * naming what the CALLER owes:
73
+ *
74
+ * โ€ข `unanchored` โ€” the delivery names no page (or no resolvable partition), so there is no row to
75
+ * address and the caller keeps today's path exactly.
76
+ * โ€ข `deliver` โ€” a plain comment on an engaged page. The caller wakes a turn even where the page's
77
+ * domain has not opted into the ambient supervisor: somebody addressed the agent on THIS page.
78
+ * โ€ข `hold` โ€” the seam does not admit it. `stopped` says WHY, and it is the one field a caller must
79
+ * branch on: `stopped: true` means the agent was asked to stand down on this page, so the comment
80
+ * must be SUPPRESSED outright โ€” including where the ambient supervisor is enabled and would
81
+ * otherwise answer. `stopped: false` means the page simply is not engaged, which is not a
82
+ * suppression: the caller keeps today's path (the domain opt-in then decides).
83
+ */
84
+ export type NotionEngagementVerdict =
85
+ | { readonly kind: "unanchored"; readonly reason: string }
86
+ | { readonly kind: "deliver"; readonly anchor: string; readonly reason: string }
87
+ | { readonly kind: "hold"; readonly anchor: string; readonly reason: string; readonly stopped: boolean };
88
+
89
+ /** Injectable seams so the whole verdict is unit-tested without a store. */
90
+ export interface NotionEngagementDeps {
91
+ readonly getEngagement?: (p: Partition, anchor: string) => Promise<EngagementRow | null>;
92
+ readonly markEngaged?: (p: Partition, anchor: string, atIso: string) => Promise<void>;
93
+ /** The durable once-guard BOTH this module's guards take โ€” the engage mark
94
+ * ({@link markNotionEngaged}) and the wake ({@link firstNotionDispatchOfComment}). They key on
95
+ * different prefixes, so one injected stub serves both. Injected so a test can drive a
96
+ * redelivered comment without an operational store. */
97
+ readonly markEventSeen?: (org: string, key: string, atIso: string) => Promise<boolean>;
98
+ readonly now?: () => string;
99
+ }
100
+
101
+ /**
102
+ * Resolve the seam for one NON-ADDRESSED comment โ€” READ-ONLY. An addressed comment never reaches
103
+ * this function: it marks instead ({@link markNotionEngaged}), so exactly one caller writes the row.
104
+ *
105
+ * THE DECISION ITSELF IS CORE'S. `shouldDeliverReply` owns the disqualifier order and the reasons;
106
+ * this function only translates Notion facts into its input. Nothing here re-states the policy.
107
+ *
108
+ * FAIL-CLOSED on a store error: `getThreadEngagement` reads a failure as `null`, so an unreadable
109
+ * registry holds rather than delivers.
110
+ *
111
+ * `authorIsBot` is the caller's own loop-guard verdict. The classifier drops a self-authored or
112
+ * integration-authored comment before this runs, so it is `false` on every live call; it stays a
113
+ * parameter rather than a hard-coded literal so the guard is visible at the call site and a test can
114
+ * drive the other direction.
115
+ */
116
+ export async function resolveNotionEngagement(
117
+ f: NotionSeamFacts,
118
+ authorIsBot: boolean,
119
+ deps: NotionEngagementDeps = {},
120
+ ): Promise<NotionEngagementVerdict> {
121
+ const anchor = notionEngagementAnchor(f);
122
+ if (!anchor) return { kind: "unanchored", reason: "the delivery names no workspace and page โ€” no row to address" };
123
+ const partition = notionSeamPartition(f);
124
+ if (!partition) return { kind: "unanchored", reason: "the delivery has no resolvable partition โ€” no row to address" };
125
+
126
+ const read = deps.getEngagement ?? ((p, key) => getThreadEngagement(p, key));
127
+ const row = await read(partition, anchor);
128
+ const decision = shouldDeliverReply({
129
+ isEngaged: row !== null,
130
+ stopped: row?.stopped ?? false,
131
+ authorIsBot,
132
+ isAppMention: false, // an addressed comment never reaches this function
133
+ inThread: true, // an anchored Notion page IS the container engagement is kept per
134
+ });
135
+ if (decision.deliver) return { kind: "deliver", anchor, reason: decision.reason };
136
+ return { kind: "hold", anchor, reason: decision.reason, stopped: row?.stopped === true };
137
+ }
138
+
139
+ /**
140
+ * Mark one page ENGAGED on an addressed (name-drop or structured-mention) comment. ONCE PER COMMENT.
141
+ *
142
+ * `participating: true` is the re-invitation: it promotes the row and CLEARS a previous stand-down,
143
+ * which is what "name the agent to bring it back" means here.
144
+ *
145
+ * ๐Ÿ”ด THE MARK IS DEDUPED, AND THE STOP IS WHY โ€” the same durable guard GitHub's and Linear's marks
146
+ * take. Clearing the flag is right for a person's second name-drop and WRONG for Notion redelivering
147
+ * the first one. THE ROUTE DOES NOT ALWAYS ACK, so the redelivery is reachable: `handleNotionDelivery`
148
+ * carries no top-level `catch`, and eve's channel dispatcher answers a throw with a 500 โ€” while this
149
+ * mark sits ABOVE `getOrCreateSession` and the wake, either of which can throw on a store outage. The
150
+ * sequence that costs is then: name-drop marks the page, the mint throws, Notion is told 500, somebody
151
+ * asks the agent to stand down, and Notion's retry of the original comment lifts the stop.
152
+ * `commentId` is the trigger comment's own id, so the guard keys on the thing that must happen once.
153
+ * DURABLE layer only, and it expires with the store's TTL โ€” a retry later than that window can still
154
+ * re-mark. FAIL-OPEN on a store error, the posture every dedupe in this repository takes.
155
+ *
156
+ * Best-effort throughout โ€” `markThreadEngaged` swallows a store failure, because engagement
157
+ * bookkeeping must never fail a turn. Returns the anchor it marked, or null when it marked nothing.
158
+ */
159
+ export async function markNotionEngaged(f: NotionSeamFacts, commentId: string | undefined, deps: NotionEngagementDeps = {}): Promise<string | null> {
160
+ const anchor = notionEngagementAnchor(f);
161
+ if (!anchor) return null;
162
+ const partition = notionSeamPartition(f);
163
+ if (!partition) return null;
164
+ const now = deps.now ?? (() => new Date().toISOString());
165
+ const atIso = now();
166
+ if (commentId !== undefined && commentId.length > 0) {
167
+ const seen = deps.markEventSeen ?? liveMarkEngageSeen;
168
+ if (!(await seen(partition.org, `notion-engage:${anchor}:${commentId}`, atIso))) return null;
169
+ }
170
+ const mark = deps.markEngaged ?? ((p, key, at) => markThreadEngaged(p, key, at, { participating: true }));
171
+ await mark(partition, anchor, atIso);
172
+ return anchor;
173
+ }
174
+
175
+ /**
176
+ * ONE WAKE PER COMMENT (OPE-1906). TRUE = this comment has not woken the agent inside the window,
177
+ * so the caller should dispatch. FALSE = the same comment id already did, so the caller must not.
178
+ *
179
+ * ๐Ÿ”ด THE OTHER HALF OF THE LOOP GUARD, AND THE CLASSIFIER CANNOT BE IT. `classifyNotionComment`
180
+ * reads one comment's author; a REDELIVERY of a person's comment carries the same author and the
181
+ * same text, so it classifies identically and dispatches again โ€” a second model turn, a second
182
+ * `notion.dispatched` row, and a second reply into the discussion. Nothing else on this road is
183
+ * keyed on the delivery, so this is the only place that can say "again".
184
+ *
185
+ * REDELIVERY IS REACHABLE, and {@link markNotionEngaged} above already says why: the route does not
186
+ * always ack. `handleNotionDelivery` carries no top-level `catch`, and eve's channel dispatcher
187
+ * answers a throw with a 500, so Notion retries. A page shared with two subscriptions delivers the
188
+ * same comment twice by design (OPE-467), and a vendor retry needs no failure of ours at all.
189
+ *
190
+ * ๐Ÿ”ด THE MARK IS TAKEN BEFORE THE WAKE, AND THAT TRADE IS DELIBERATE. Marking afterwards would let
191
+ * two concurrent deliveries of one comment both run a turn, which is the cost this guard exists to
192
+ * stop; `markEventSeen` is atomic across instances, so marking first stops them. The price is that
193
+ * a delivery whose dispatch THROWS is not retried inside the window โ€” the same price the engage
194
+ * mark one step above already pays, and the reason this guard sits as LATE as it can, below every
195
+ * cheaper refusal, so only a delivery that was really about to wake a turn ever spends a mark.
196
+ *
197
+ * ๐Ÿ”ด A SUITE THAT DRIVES `handleNotionDelivery` PAST THIS POINT MUST INJECT `markEventSeen`, or
198
+ * pin its own store path. The default is the DURABLE operational store, and an unpinned suite in
199
+ * this package shares one sqlite file (scripts/suite-store-isolation-lint.ts says why) โ€” so a suite
200
+ * that replays a fixed comment id writes a mark that survives the run, and its NEXT run reads
201
+ * `duplicate-delivery` where it expected `dispatched`. `lint:suite-store` cannot catch that: its
202
+ * reach test is a source scan, and such a suite names no store entry point of its own. Five suites
203
+ * inject the stub today.
204
+ *
205
+ * FAIL-OPEN, three ways: a store error, an unresolvable partition and a delivery with no comment id
206
+ * all answer TRUE, so an outage costs a duplicate turn rather than a deaf deployment. The try/catch
207
+ * covers the INJECTED edge too, not just the live default (the shape webhooks/replay-dedupe.ts and
208
+ * webhooks/reject-audit.ts take) โ€” which is how a test drives the fail-open arm without breaking a
209
+ * real store. `commentId` is the thing that must happen once, and a Notion comment id is globally
210
+ * unique, so the key needs no anchor.
211
+ */
212
+ export async function firstNotionDispatchOfComment(
213
+ f: NotionSeamFacts,
214
+ commentId: string | undefined,
215
+ deps: NotionEngagementDeps = {},
216
+ ): Promise<boolean> {
217
+ if (commentId === undefined || commentId.length === 0) return true;
218
+ const partition = notionSeamPartition(f);
219
+ if (!partition) return true;
220
+ const now = deps.now ?? (() => new Date().toISOString());
221
+ const seen = deps.markEventSeen ?? liveMarkDispatchSeen;
222
+ try {
223
+ return await seen(partition.org, `notion-dispatch:${commentId}`, now());
224
+ } catch {
225
+ return true;
226
+ }
227
+ }
228
+
229
+ /** The two addressing values a Notion delivery can carry into a dispatch โ€” `drop` never reaches one. */
230
+ export type NotionDispatchAddressing = "direct" | "channel-watch";
231
+
232
+ /**
233
+ * WHAT WOKE ONE ADMITTED DELIVERY, in the wake envelope's own vocabulary (core's `WakeAddressing`).
234
+ * It is the value the `notion.dispatched` audit row reports, and NO PRODUCT CODE BRANCHES ON IT.
235
+ * Every reader is an EVIDENCE reader outside the product: the journey pack's `reportedThreadFollow`
236
+ * (packages/core/scripts/e2e/engaged-rows.ts) gates `J-ENGAGED-notion`'s follow-up step on it,
237
+ * `drive-notion-namedrop.ts` prints and asserts it, and the test tiers read it. So changing the
238
+ * value moves what the pack can prove; it moves no delivery decision.
239
+ *
240
+ * ๐Ÿ”ด IT IS DELIBERATELY NOT THE CLASSIFIER'S VALUE. The classifier answers one question โ€” did this
241
+ * comment ADDRESS the agent โ€” and an engaged follow-up did not, so it stays `channel-watch`. The
242
+ * supervisor core needs that: `channel-watch` is what makes the turn a JUDGED one, and promoting it
243
+ * would silence the very wake this seam exists to admit.
244
+ *
245
+ * ๐Ÿ”ด BUT `channel-watch` ALSO NAMES UNOWNED ROOM CHATTER on an ambient page, and the audit row is
246
+ * the one place those two must not read alike. A row that reported `channel-watch` for both cannot
247
+ * tell an ENGAGED wake from an AMBIENT one, and that distinction is the whole evidentiary worth of
248
+ * the ยง2.5 seam on this surface: it is what `J-ENGAGED-notion` gates its follow-up step on
249
+ * (`reportedThreadFollow`, packages/core/scripts/e2e/engaged-rows.ts). Until OPE-1501 the row was
250
+ * unreachable on an addressed-only page โ€” the ambient gate returned before the classifier ran โ€” so
251
+ * the collision cost nothing. The gate now admits an engaged follow-up, and the collision costs the
252
+ * proof, which is what OPE-1510 fixed here.
253
+ *
254
+ * ๐Ÿ”ด IT REPORTS THE REGISTRY, NOT THE DOOR. The seam is read for every plain comment, above and
255
+ * independently of the ambient gate, so on an ambient-ENABLED page a plain comment on an engaged
256
+ * page also reads `thread-follow` โ€” the ambient flag would have admitted it either way. The value
257
+ * therefore separates an engaged wake from an ambient one only where the domain has ambient OFF,
258
+ * which is the addressed-only posture the journeys drive.
259
+ *
260
+ * `engagedFollowUp` is the webhook core's own seam verdict, and the `channel-watch` guard below is
261
+ * belt-and-braces: an ADDRESSED comment never asks the seam, so it can never carry the flag.
262
+ */
263
+ export function notionWakeAddressing(
264
+ addressing: NotionDispatchAddressing,
265
+ engagedFollowUp: boolean | undefined,
266
+ ): "direct" | "channel-watch" | "thread-follow" {
267
+ return addressing === "channel-watch" && engagedFollowUp === true ? "thread-follow" : addressing;
268
+ }
269
+
270
+ /** The `notion.dispatched` row's two seam fields: what woke the delivery, and how the operator's
271
+ * config holds the page. */
272
+ export interface NotionWakeAuditDetail {
273
+ readonly addressing: "direct" | "channel-watch" | "thread-follow";
274
+ readonly via: NotionVia;
275
+ }
276
+
277
+ /**
278
+ * THE SEAM'S WHOLE CONTRIBUTION TO THE AUDIT ROW, as one pure mapping.
279
+ *
280
+ * ๐Ÿ”ด THE TWO FIELDS ANSWER DIFFERENT QUESTIONS, AND A ROW NEEDS BOTH. `addressing` says WHAT WOKE
281
+ * the delivery. `via` says HOW THE OPERATOR DECLARED THE PAGE, which is the domain origin the
282
+ * addressed door gated on (`notionPageDomain`, channels/notion-scope.ts). A `container` claim
283
+ * reaches pages NOBODY ENUMERATED โ€” the whole workspace, or a same-level page glob, which are the
284
+ * two shapes a container entry may take (memory/domain.ts); a `roster` entry is a page an
285
+ * operator wrote down. Both reach the same door, and a row carrying only `addressing` reports them
286
+ * identically โ€” which leaves the wider admission looking exactly like the narrower one in the
287
+ * audit trail. The row says WHICH KIND of claim, never how wide that claim is: read the domain's
288
+ * own container entry for the breadth.
289
+ *
290
+ * `via` is the webhook core's own read, carried on the dispatch. An absent field reads `none`: the
291
+ * conservative word, so a caller that omits it can never claim a declaration it did not make.
292
+ */
293
+ export function notionWakeAuditDetail(
294
+ addressing: NotionDispatchAddressing,
295
+ engagedFollowUp: boolean | undefined,
296
+ via: NotionVia | undefined,
297
+ ): NotionWakeAuditDetail {
298
+ return { addressing: notionWakeAddressing(addressing, engagedFollowUp), via: via ?? "none" };
299
+ }
300
+
301
+ /** How long this module remembers one comment id โ€” the window both durable guards below key on.
302
+ * It bounds the vendor retry both of them answer, so ONE number serves both: a redelivery this
303
+ * module treats as "again" for the mark must be "again" for the wake too, or the two guards would
304
+ * disagree about the same delivery. */
305
+ const NOTION_DEDUPE_TTL_MS = 10 * 60_000;
306
+
307
+ /** The durable half of the mark dedupe. FAIL-OPEN: an unreachable store answers "not seen", so a
308
+ * store outage costs a redundant mark rather than a lost engagement. */
309
+ let warnedEngageDedupe = false;
310
+ async function liveMarkEngageSeen(org: string, key: string, atIso: string): Promise<boolean> {
311
+ try {
312
+ return await getOperationalStore().markEventSeen(org, key, atIso, NOTION_DEDUPE_TTL_MS);
313
+ } catch (err) {
314
+ if (!warnedEngageDedupe) {
315
+ warnedEngageDedupe = true;
316
+ console.warn(`[open-everywhere:notion-engagement] engage dedupe unavailable โ€” a redelivered name-drop may re-mark: ${String(err)}`);
317
+ }
318
+ return true;
319
+ }
320
+ }
321
+
322
+ /** The durable half of the WAKE once-guard ({@link firstNotionDispatchOfComment}). Same window as
323
+ * the mark above, and the same FAIL-OPEN posture: an unreachable store costs a duplicate turn,
324
+ * never a silenced comment. */
325
+ let warnedDispatchDedupe = false;
326
+ async function liveMarkDispatchSeen(org: string, key: string, atIso: string): Promise<boolean> {
327
+ try {
328
+ return await getOperationalStore().markEventSeen(org, key, atIso, NOTION_DEDUPE_TTL_MS);
329
+ } catch (err) {
330
+ if (!warnedDispatchDedupe) {
331
+ warnedDispatchDedupe = true;
332
+ console.warn(`[open-everywhere:notion-engagement] dispatch dedupe unavailable โ€” a redelivered comment may wake a second turn: ${String(err)}`);
333
+ }
334
+ return true;
335
+ }
336
+ }
337
+
338
+ // โ”€โ”€ THE EXACT-JOIN IDS ON THE `notion.dispatched` ROW (OPE-1907) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
339
+
340
+ /** The three ids that let one Notion delivery be joined to the turn it woke. IDS ONLY. */
341
+ export interface NotionDispatchJoinIds {
342
+ /** The WEBHOOK ENVELOPE's own `id` โ€” unique to this delivery, so two deliveries of the SAME
343
+ * comment are tellable apart. Absent when the body carried no id. */
344
+ readonly deliveryId?: string;
345
+ /** The TRIGGER COMMENT's id โ€” what the delivery named, and what a replay leg configures. */
346
+ readonly commentId?: string;
347
+ /** The single-window wake this delivery minted โ€” the join key `wake.ingress` and
348
+ * `wake.turn_start` carry, and therefore the bridge to the session that answered. Absent on a
349
+ * per-doc (non-unified) delivery, which mints no window wake. */
350
+ readonly wakeId?: string;
351
+ }
352
+
353
+ /**
354
+ * Build the join ids for the row. Pure, and EVERY FIELD IS AN ID โ€” never a comment body, an author
355
+ * name or a page title.
356
+ *
357
+ * ๐Ÿ”ด WHY THE ROW NEEDS THEM. Before this, `notion.dispatched` recorded the discussion and the
358
+ * addressing but nothing that identified the DELIVERY, so a leg that forged one replay could only
359
+ * scan a time window for a verdict row and hope no other wake fell inside it. S-TAINT-DRYRUN Arm B
360
+ * did exactly that and the scan could not tell its own replay's verdict from an ambient one
361
+ * (OPE-1907). With the delivery id on the row, one forge joins one wake joins one session.
362
+ *
363
+ * An absent value is OMITTED rather than written as `undefined`: an audit row that carries a key
364
+ * with no value reads, to anyone querying it, like a value that was looked for and found empty.
365
+ */
366
+ export function notionDispatchJoinDetail(ids: NotionDispatchJoinIds): Record<string, string> {
367
+ const out: Record<string, string> = {};
368
+ if (ids.deliveryId) out.deliveryId = ids.deliveryId;
369
+ if (ids.commentId) out.commentId = ids.commentId;
370
+ if (ids.wakeId) out.wakeId = ids.wakeId;
371
+ return out;
372
+ }