| 1 | # 🤖 memoria — Notion ingest |
| 2 | |
| 3 | **Status:** draft 2. Draft 1 reviewed by `claude-fable-5-1`, REQUEST |
| 4 | CHANGES, 15 findings — §1.1 records what it changed, including three |
| 5 | blockers and one finding that removed the enumeration design entirely. |
| 6 | **Parent:** `2026-09-03-memoria-design.md`. Where this disagrees with the |
| 7 | parent, the parent wins and this document is wrong — fix it in the same |
| 8 | commit. §3 does exactly that: it changes the parent, and says so. |
| 9 | |
| 10 | This is the first half of the parent's **slice 3** (§11, external |
| 11 | connectors), under the parent's §4.2: content memoria owns outright, |
| 12 | because there is no sibling to ask. |
| 13 | |
| 14 | --- |
| 15 | |
| 16 | ## 0. The one rule, as it applies here |
| 17 | |
| 18 | The parent's rule is *memoria never speaks a passage without asking |
| 19 | whoever owns it whether this person may see it, right now.* For Notion |
| 20 | there is nobody to ask, and the parent already says so (§4.2): **memoria |
| 21 | is the app of record for what it ingests from Notion.** The sharing act |
| 22 | at the far end was the publish. §12 makes sure the product says this |
| 23 | rather than implying otherwise. |
| 24 | |
| 25 | The second rule is unchanged, and this document adds to what it covers: a |
| 26 | Notion page's **title, body, headings and ancestry path are all content** |
| 27 | and all sealed. Its page id is a coordinate — sealed reversibly, and |
| 28 | separately fingerprinted for lookup (§8, and §1.1 finding 1 for why draft |
| 29 | 1's version could not work at all). |
| 30 | |
| 31 | --- |
| 32 | |
| 33 | ## 1. Settled decisions |
| 34 | |
| 35 | | # | Decision | Because | |
| 36 | |---|---|---| |
| 37 | | 1 | Notion ships **without** slices 1 and 2 | §4.2 needs no sibling contract, no person ref, no model. §2 | |
| 38 | | 2 | Enumerate by **traversal from shared roots**, never by `/v1/search` | Notion documents that search "is not guaranteed to return everything". §6 — this is the finding that rebuilt the design | |
| 39 | | 3 | A sync **never deletes on absence** | memoria is replacing Notion; a page vanishing upstream is the expected end state. **This changes the parent** — §3 | |
| 40 | | 4 | One Notion page → one memoria document | The page is the unit people share, name and link to. §5 | |
| 41 | | 5 | Ancestry is carried, in **its own index column at low weight** | Nesting is how a workspace is navigated; the existing heading column is 3×-weighted and would rank the whole workspace above the answer. §7 | |
| 42 | | 6 | `Notion-Version: 2026-03-11`, pinned as a constant with a gate | The object model changed incompatibly at 2025-09-03 (data sources). An unpinned client is one release from silent extraction loss. §1.1 finding 5 | |
| 43 | | 7 | The credential is sealed under its own HKDF purpose | Parent §3.5, verbatim: "so a chunk-store disclosure does not yield them" | |
| 44 | | 8 | The run is admin-triggered and resumable, not scheduled | The app sleeps. §9 says what that costs and refuses to hide it | |
| 45 | | 9 | **One package may reach the network**, host-pinned at runtime | The existing gate forbids all egress; the fix is to narrow it to something stronger, never to delete it. §11 | |
| 46 | | 10 | Ship in **three stages**, ranking first | Stage 1 re-ranks every existing document and deserves its own review. §14 | |
| 47 | |
| 48 | ### 1.1 What the Fable review changed |
| 49 | |
| 50 | Recorded rather than quietly folded in, in the parent's §1.2–§1.4 style. |
| 51 | Findings 1, 2, 3 were blockers; finding 4 was the expensive one. |
| 52 | |
| 53 | - **The ledger could not fetch anything (blocker).** Draft 1 stored only |
| 54 | a *keyed* page id, and `seal.Fingerprint` is HMAC-SHA256 |
| 55 | (`seal/seal.go:106`) — one-way. Phase 2 was specified as |
| 56 | `GET /v1/blocks/{page_id}/children` over ledger rows, so a resumed run |
| 57 | held 1,930 fingerprints and no URL to call. "Resumption is free and |
| 58 | needs no separate mechanism" was true only because the mechanism was |
| 59 | absent. Now: a **sealed** page id (`PurposeCoordinate`, reversible) |
| 60 | plus the print for lookup. |
| 61 | - **Search cannot enumerate, and there is no signal when it fails to |
| 62 | (finding 4, and it removed the design).** Draft 1 promised a total |
| 63 | decoder: "every enumeration either yields every item or fails." Notion's |
| 64 | own documentation says the search endpoint is not optimized for |
| 65 | "exhaustively enumerating through all the documents that a bot has |
| 66 | access to" and that **"search is not guaranteed to return everything"**, |
| 67 | with no field in the response saying so. A clean run to |
| 68 | `has_more: false` can be missing pages, and draft 1 would have reported |
| 69 | a complete import. That is the codebase's worst failure shape produced |
| 70 | by the choice of endpoint, and **gate N1 would have stayed green while |
| 71 | the property it named was violated** — a guard written to pass, at the |
| 72 | architecture level rather than the test level. §6 replaces it with |
| 73 | traversal. The reviewer proposed a `request_status` field as the fix; |
| 74 | that field is not in the documentation and the real situation is worse |
| 75 | than the finding, which is why §6 is a redesign and not a decoder patch. |
| 76 | - **Single-flight wedged the connector on the normal path (blocker).** |
| 77 | The run is a detached goroutine; when CARLOS sleeps the app mid-fetch |
| 78 | the goroutine dies without writing anything and the row stays |
| 79 | `fetching`. Nothing in draft 1 ever wrote `interrupted`, so every later |
| 80 | sync was refused as "already running", and §12's "interrupted at 412 of |
| 81 | 1,930" copy had no writer. Draft 1 described the deadlock as a feature |
| 82 | ("survives a restart that left a run marked active"). Now: boot-time |
| 83 | reclaim plus a heartbeat, §9. |
| 84 | - **Gate N3 could not go red (finding 7, the best one).** FTS5's `bm25()` |
| 85 | sums per-column contributions, so a row matching in *both* path and |
| 86 | body outranks a path-only match under any positive weights. Draft 1's |
| 87 | fixture stayed green with the mutation applied — the gate written to |
| 88 | catch §7's scoring trap could not catch it. §13 specifies the fixture |
| 89 | that reproduces the trap and requires the flip to be **measured**, not |
| 90 | assumed. |
| 91 | - **Renames and moves never reached memoria.** `store.go:113` short- |
| 92 | circuits on an unchanged body print without touching `sealed_title`, |
| 93 | so a renamed page keeps its old title forever; an *ancestor* rename |
| 94 | does not change a descendant's `last_edited_time` at all, so the fetch |
| 95 | phase skipped it before `Ingest` was even called. And the ledger had |
| 96 | one edit-time column, so the phase-2 comparison compared it against |
| 97 | itself. §10. |
| 98 | - **Empty pages could not be ingested.** `store.go:73` refuses an empty |
| 99 | body; real workspaces are full of title-only pages and placeholders. |
| 100 | Draft 1's gate N2 asserted an empty body "ingests" and would have |
| 101 | failed before its mutation was applied. §5. |
| 102 | - **A page that failed to fetch had nowhere to be recorded**, so three |
| 103 | unreadable pages inside 1,930 rendered as a complete import. §8 gives |
| 104 | every ledger row a state and an error code. |
| 105 | - **"Owners only" named a distinction slice 0 does not have.** |
| 106 | `MEMORIA_OWNERS` is the entire admission list (`config.go:65`, |
| 107 | `auth.go:28`); today "owners" ≡ "signed in". Worse, `AppForTest` |
| 108 | bypasses `RequireSession`, so no identity reaches the request context |
| 109 | and the gate could not be written at all. §12. |
| 110 | - **Decision 3 contradicts the parent, and draft 1 claimed it agreed.** |
| 111 | Draft 1 cited parent §3.6 as proof. §3 now argues it as a change and |
| 112 | amends the parent in this commit, which is what this document's own |
| 113 | header requires. |
| 114 | - Smaller: `Retry-After` applies to **529** as well as 429; a Notion |
| 115 | 404 body quotes the page id *and* the connection name, which is a |
| 116 | concrete reason for §8's code-not-message rule; the sync route must be |
| 117 | mounted inside the CSRF and session group; `total` needs the codebase's |
| 118 | `-1` sentinel; there is no disconnect affordance though parent §3.6 |
| 119 | promises one; `context.WithoutCancel` in `store.go:189` is a bounded |
| 120 | post-commit refresh, not a precedent for a multi-minute job. |
| 121 | |
| 122 | ### 1.2 What building it changed |
| 123 | |
| 124 | Draft 2 was written before the code. Five things did not survive contact, |
| 125 | and they are recorded here rather than quietly diverged from. |
| 126 | |
| 127 | - **One pass, not two phases.** §9 specified enumerate-then-fetch. With |
| 128 | traversal they are the same API call — fetching a page returns its body |
| 129 | *and* the child pages under it — so running them separately would |
| 130 | double every request against a limit of three per second. The loop is |
| 131 | one pass; the ledger is still written per page. |
| 132 | - **Therefore the interruption cost is larger than §9 claimed, and the |
| 133 | progress display is different.** "The cost of an interruption is the |
| 134 | page in flight" is false: the *ingest* work already done survives, but |
| 135 | a resumed run repeats the traversal, because a page's children cannot |
| 136 | be discovered without asking and a child's own edit does not change its |
| 137 | parent's edit time. And since the total is only known when the walk |
| 138 | ends, the status page cannot say "412 of 1,930" mid-run. It says how |
| 139 | many pages have been seen so far and that the total is not yet known — |
| 140 | `total` stays at the `-1` sentinel until the run completes. |
| 141 | - **There was no way to rotate a token.** §12 said "rotation is replacing |
| 142 | the row" and the page offered no form to do it with; the only route was |
| 143 | disconnect and reconnect. Found by mutation-testing |
| 144 | `TestTokenIsNeverRendered`, which was passing because the page it |
| 145 | looked at had no token field at all. |
| 146 | - **Ancestry was indexed and never shown.** Search results rendered the |
| 147 | in-page heading trail and not the source path, so two pages called |
| 148 | "Week one" under different handbooks were indistinguishable to the |
| 149 | reader. Carrying ancestry for findability while hiding it from the |
| 150 | person reading the answer is half a feature. Found by looking at the |
| 151 | rendered page, which is the only thing that would have found it. |
| 152 | - **The scheme pin became its own check.** §11 described a host pin. The |
| 153 | implementation also rejected non-loopback plaintext hosts, and that |
| 154 | second condition caught the host-pin gate's test target — so deleting |
| 155 | the host pin left the gate green. Host and scheme are now separate |
| 156 | fields with separate checks and separate gates. |
| 157 | |
| 158 | Two smaller ones: `tools/drive` wrote to a hardcoded absolute path under |
| 159 | one person's home directory and panicked anywhere else, now the OS temp |
| 160 | dir; and the connector page ordered token rotation above import status, |
| 161 | which put a rare housekeeping act between the reader and the thing the |
| 162 | page is for. |
| 163 | |
| 164 | |
| 165 | ### 1.3 What the Fable code review changed |
| 166 | |
| 167 | The branch was reviewed by `claude-fable-5-1` before merge — REQUEST |
| 168 | CHANGES, 12 findings, one blocker. Recorded here, as §1.1 and §1.2 are. |
| 169 | |
| 170 | - **Nested pages were ingested as roots with no ancestry (blocker).** |
| 171 | `Roots` kept every search result whose parent was not itself in the |
| 172 | result set, on the assumption — taken from this design, and wrong — |
| 173 | that search returns only directly-shared pages. Notion's reference says |
| 174 | it returns "all parent or child pages ... shared with a connection", |
| 175 | and a page nested in a toggle has a `block_id` parent, which search |
| 176 | never returns. Such a page therefore always looked like a root, was |
| 177 | queued first, was ingested with an EMPTY path and marked visited, and |
| 178 | the correct Ref was discarded when the traversal reached it. On the |
| 179 | next run search might include the ancestor and the page would flap |
| 180 | between two ancestries, re-ingesting each time. The fake had a |
| 181 | `searchLeaks` switch built for exactly this and no test using it. Roots |
| 182 | and orphans are now separate, and orphans are walked only after the |
| 183 | real traversal drains. |
| 184 | - **A page was not findable by its own title.** A Notion title is a |
| 185 | property, not a block, so it reached none of heading, body or path. |
| 186 | Fixed by the merge with main, which had already found the same bug for |
| 187 | pasted documents and added a title column. |
| 188 | - **The heartbeat was counted in pages, not time**, so a slow-but-healthy |
| 189 | run could be declared stale, "reclaimed", and joined by a second |
| 190 | goroutine walking the same ledger. It is a 20-second ticker now. |
| 191 | - **The status page said "0 so far" for a whole run**, because the |
| 192 | counters were written only at completion — a count maintained nowhere, |
| 193 | rendered as a confident zero, on the page written to honour that rule. |
| 194 | The ticker publishes them. |
| 195 | - **Database rows imported with no properties**, and `Properties` was |
| 196 | dead code. §5 promised the key/value block and warned that without it |
| 197 | "a forty-row database becomes forty blank documents"; that is precisely |
| 198 | what shipped. |
| 199 | - **Data sources sat in the ledger as pages that never import**, so the |
| 200 | page read "1 of 2 imported" forever. Containers are no longer |
| 201 | ledgered — and "imported" now counts rows memoria HOLDS A DOCUMENT for, |
| 202 | not rows whose last fetch happened to succeed, so a page that 404s |
| 203 | after importing no longer drops out of its own total. |
| 204 | - **Ledger writes failed silently.** `pageRow` returned `(nil, nil)` on |
| 205 | any error, so a database failure was indistinguishable from "never seen |
| 206 | this page" and the mark was skipped with nothing logged. And a failure |
| 207 | on a page memoria already held wiped its `document_id`, erasing the |
| 208 | record of a document that was still there. |
| 209 | - Smaller: the production-seam gate greped only for `AppForTest`, so |
| 210 | `AsPerson` in main would have compiled and made every request an owner; |
| 211 | `Retry-After` was uncapped and ignored the context, so one hostile |
| 212 | value parked a run for a day; the pagination cursor went into a query |
| 213 | string unescaped; database failures reported as `network`, so the page |
| 214 | said "We could not reach Notion" about a local write; and two gate |
| 215 | comments claimed more than their gates tested. |
| 216 | |
| 217 | ### 1.4 Sequencing, as it actually went |
| 218 | |
| 219 | §14 says stage 1 lands and is reviewed on its own. It did not: all three |
| 220 | stages landed on one branch and were reviewed together. Recorded rather |
| 221 | than quietly diverged from — the cost was a larger review than the design |
| 222 | intended, and the review found a blocker that a stage-2-only review would |
| 223 | have found sooner. |
| 224 | |
| 225 | **§15's ship requirement is still unmet.** No run against a real Notion |
| 226 | workspace has happened. Finding 1 above is exactly the failure §15 |
| 227 | predicts from "a connector verified only against fixtures its author also |
| 228 | wrote": it was found by a reviewer reading Notion's documentation, not by |
| 229 | a test. That requirement stands. |
| 230 | |
| 231 | --- |
| 232 | |
| 233 | ## 2. Why this is unblocked, when the parent sequenced it fourth |
| 234 | |
| 235 | The parent's §11 puts external connectors in slice 3, behind the sibling |
| 236 | contract (slice 1) — which parent §13 records as "blocked on agreement, |
| 237 | not on code", in *other repositories*. That ordering was never a |
| 238 | dependency. Slice 3's stated requirements are least-privilege credentials |
| 239 | (parent §3.5) and gate 7, both local. Notion content is memoria-owned |
| 240 | under §4.2, so: |
| 241 | |
| 242 | - **No S2 call.** There is no sibling that could answer one. |
| 243 | - **No person ref**, so no dependency on home §13 step 4. |
| 244 | - **No content egress.** Bodies come *in*. The footer's claim — "no |
| 245 | document ever leaves this instance" — stays true and |
| 246 | `TestFooterDoesNotOverclaim` stays green. |
| 247 | |
| 248 | Draft 1 said "Notion sits on slice 0 and nothing else", which the review |
| 249 | corrected: it also sits on slice 0's **test infrastructure**, because |
| 250 | `TestNoOutboundNetworkInAppCode` forbids the client this design needs |
| 251 | (§11), and on `AppForTest` growing an identity seam (§12). Those are |
| 252 | dependencies. They are in this repository, which is the point. |
| 253 | |
| 254 | --- |
| 255 | |
| 256 | ## 3. Deletion: this changes the parent |
| 257 | |
| 258 | **Custody** is the integration's share list. An internal integration sees |
| 259 | only what is shared with it, and that act of sharing is the publish |
| 260 | (parent §5). memoria adds no second selector — parent §3.5's |
| 261 | "owner-selected subsets" is satisfied by the far end already having a |
| 262 | per-page control, and a second list to keep in step with a list is the |
| 263 | shape decision 11 rejected. |
| 264 | |
| 265 | **Authorization** is memoria's own admission gate and nothing else |
| 266 | (§4.2). Every admitted person sees every Notion document memoria holds. |
| 267 | |
| 268 | **On absence, memoria does nothing.** |
| 269 | |
| 270 | | Upstream event | memoria does | |
| 271 | |---|---| |
| 272 | | Page edited, renamed, or moved | re-ingest (§10 — all three, which draft 1 got wrong) | |
| 273 | | Page added under a shared root | ingest | |
| 274 | | Page unshared, trashed, or the token revoked | **nothing.** The document stands until someone deletes it *in memoria* | |
| 275 | |
| 276 | **This is a change to the parent, not an application of it.** Parent §4.2 |
| 277 | says "§5.1's enumeration diff is a convenience that eventually notices |
| 278 | upstream deletions", and parent §5.1's Notion row records `Delete: diff |
| 279 | only`. Draft 1 removed the diff and cited parent §3.6's README paragraph |
| 280 | as evidence the parent already agreed. It is not evidence: §3.6 is about |
| 281 | *disconnecting a space or revoking access*, a different event from a |
| 282 | single page archived while the connection stays live. The review was |
| 283 | right to call that smuggled. |
| 284 | |
| 285 | **The argument for the change, made properly.** memoria is being adopted |
| 286 | as the replacement for Notion, not as a mirror of it. Under a |
| 287 | replacement, archiving a page in Notion *after* migrating it is the |
| 288 | intended workflow — and a diff-and-delete connector reads that as |
| 289 | "forget it", deleting from the app of record the document the migration |
| 290 | existed to preserve. A convenience that eventually notices deletions is |
| 291 | the wrong convenience when deletion upstream is the goal. |
| 292 | |
| 293 | **What it costs, and the mitigation.** A page deleted upstream *because |
| 294 | it should not have been shared* stays indexed and readable by every |
| 295 | admitted person, with no notice. That is a real cost and §12 puts it in |
| 296 | the interface. Every ledger row therefore carries **`last_seen_at`**, |
| 297 | written on every enumeration — it costs nothing, since traversal already |
| 298 | touches every row — so the "no longer in Notion" view is buildable later |
| 299 | without a migration. Decision 3 keeps the documents; it does not license |
| 300 | being unable to list them. |
| 301 | |
| 302 | **Parent amendments required in this commit:** §4.2's "eventually notices |
| 303 | upstream deletions" and §5.1's Notion `Delete` cell. Not doing so leaves |
| 304 | two specs that contradict, which is the drift CLAUDE.md exists to |
| 305 | prevent. |
| 306 | |
| 307 | --- |
| 308 | |
| 309 | ## 4. The Notion object model we are coding against |
| 310 | |
| 311 | Pinned at **`Notion-Version: 2026-03-11`**, a constant in the client with |
| 312 | a gate asserting it. This matters more than a pin usually does: version |
| 313 | `2025-09-03` changed the object model incompatibly. A **database** now |
| 314 | contains one or more **data sources**; a row's parent is |
| 315 | `{type: "data_source_id", data_source_id, database_id}`; |
| 316 | `/v1/databases/{id}/query` is replaced by `/v1/data_sources/{id}/query`. |
| 317 | `2026-03-11` further renames `archived` → `in_trash` and the |
| 318 | `transcription` block type → `meeting_notes`. Draft 1 described the |
| 319 | pre-2025 model and listed pinning as an *open item*; it is a decision |
| 320 | with consequences for every parent link the ancestry walk reads. |
| 321 | |
| 322 | Trashed content is skipped: `in_trash: true` is not ingested, and an |
| 323 | already-ingested page that goes to trash is an absence, which by decision |
| 324 | 3 means nothing happens. |
| 325 | |
| 326 | --- |
| 327 | |
| 328 | ## 5. What a Notion page becomes |
| 329 | |
| 330 | One page → one `Document`, `Source = "notion"`. |
| 331 | |
| 332 | **Body** is Markdown extracted from the block tree: paragraph, |
| 333 | `heading_1..3` → `#`/`##`/`###`, bulleted and numbered list items, |
| 334 | `to_do` → `- [ ]`/`- [x]`, `quote`, `callout`, `toggle` (heading line |
| 335 | then children), `code` (fenced, language preserved), `divider`, `table` |
| 336 | (pipe table), `image`/`file`/`video`/`pdf`/`meeting_notes` (caption and |
| 337 | filename only — memoria indexes text), and `child_page`/`child_database` |
| 338 | as a one-line reference, **never inlined**: they are their own documents |
| 339 | and inlining would duplicate every passage into all of its ancestors. |
| 340 | Rich text uses Notion's `plain_text`, so an inline equation or mention |
| 341 | degrades to the text Notion itself renders. |
| 342 | |
| 343 | **Unknown block types produce nothing and increment a counter.** Not an |
| 344 | error — Notion adds block types, and a connector that fails a run on an |
| 345 | unrecognised one breaks on Notion's release schedule. But not silence |
| 346 | either: the run reports "N blocks of a type memoria does not read", so a |
| 347 | page that came in thin has a reason attached rather than looking like a |
| 348 | thin page. |
| 349 | |
| 350 | **Empty pages are ingested as title-only documents.** Draft 1 said an |
| 351 | empty page "ingests as an empty body"; `store.go:73` and `:101` refuse |
| 352 | exactly that, so the gate asserted something the store forbids. Real |
| 353 | workspaces are full of placeholders and title-only rows, and both |
| 354 | available alternatives were wrong: failing the run lets one placeholder |
| 355 | block the whole import, and skipping makes the page an absence with no |
| 356 | row to explain it. So `Store.Ingest` grows an explicit |
| 357 | `AllowTitleOnly` option — off for uploads and paste, where an empty |
| 358 | submission is a user error worth refusing, on for connectors, where it is |
| 359 | a fact about the source. The document's body is its title; it chunks to |
| 360 | one passage; the ledger row records `empty` and the run counts it. |
| 361 | |
| 362 | **Databases.** A database is not a document; each **row is a page** and |
| 363 | becomes one, with properties rendered as a leading key/value block: |
| 364 | |
| 365 | ``` |
| 366 | Status: Shipped |
| 367 | Owner: Ana |
| 368 | Due: 2026-03-01 |
| 369 | |
| 370 | <the row page's blocks> |
| 371 | ``` |
| 372 | |
| 373 | `relation` renders the related pages' titles, `rollup` and `formula` |
| 374 | their computed value. **This is the part most likely to be wrong** — a |
| 375 | 40-row database becomes 40 one-paragraph documents, which may be noise |
| 376 | rather than memory. §15 records it as unanswerable without a real |
| 377 | workspace. |
| 378 | |
| 379 | **Title** is the page's title property. A database row with an empty |
| 380 | title gets its data source's name plus its first non-empty property, |
| 381 | because `firstLine(body)` over a property block titles every document |
| 382 | `Status: Shipped`. |
| 383 | |
| 384 | **External id** is the Notion page id — stable across edits, renames and |
| 385 | moves, which is what `Document.ExternalID`'s contract requires. It is |
| 386 | keyed through `seal.Fingerprint` like every other external id (parent |
| 387 | §3.2: a coordinate must not be an oracle). |
| 388 | |
| 389 | --- |
| 390 | |
| 391 | ## 6. Enumeration: traversal, not search |
| 392 | |
| 393 | **Draft 1 enumerated with `/v1/search` and promised a total decoder. It |
| 394 | cannot be done.** Notion's documentation is explicit that search "is not |
| 395 | optimized for … exhaustively enumerating through all the documents that a |
| 396 | bot has access to", that **"search is not guaranteed to return |
| 397 | everything"**, and that its index is eventually consistent. Crucially |
| 398 | there is *no field in the response that says the result was partial*. So |
| 399 | a run that pages cleanly to `has_more: false`, with no 429 and no error, |
| 400 | may simply be missing pages — and would report a complete import. No |
| 401 | decoder, however total, can detect it. |
| 402 | |
| 403 | The same documentation supplies the way out: |
| 404 | |
| 405 | > any pages or databases that are **directly shared** with a connection |
| 406 | > are **guaranteed to be returned** |
| 407 | |
| 408 | So search is reliable for exactly one thing, and that is what we use it |
| 409 | for: |
| 410 | |
| 411 | 1. **Roots.** `POST /v1/search` finds the directly-shared pages and data |
| 412 | sources — guaranteed complete for *that* set, which is the set an |
| 413 | admin chose. |
| 414 | 2. **Traversal.** From each root, walk down: `GET /v1/blocks/{id}/children` |
| 415 | for `child_page` and `child_database`, and |
| 416 | `POST /v1/data_sources/{id}/query` for rows. Both are cursor-paginated |
| 417 | with a proper `has_more`/`next_cursor` contract, and both are |
| 418 | *complete* — they enumerate a parent's actual children, not an index's |
| 419 | opinion of them. |
| 420 | |
| 421 | **Ancestry falls out for free.** You arrive at every page *from* its |
| 422 | parent, so the path is known at the moment of fetch. Draft 1 walked |
| 423 | `parent` links upward, which needed raw ids of pages possibly outside the |
| 424 | run, had to hop `block_id` parents for inline child pages, and truncated |
| 425 | silently when an ancestor was unshared. None of that exists now. |
| 426 | |
| 427 | **The decoder is total** — the parent's §4.1 rule, applied where it can |
| 428 | actually hold, because these endpoints support it: |
| 429 | |
| 430 | - `has_more: true` with no `next_cursor` → error. |
| 431 | - A repeated cursor → error. (A looping API would otherwise produce a run |
| 432 | that never finishes and never says why.) |
| 433 | - A short page with `has_more: true` → accepted; the *cursor* is the |
| 434 | completeness signal, not the count. |
| 435 | - `429`/`529` past the retry budget → error. Both carry `Retry-After` in |
| 436 | integer seconds and both are honoured exactly; `500/502/503/504` are |
| 437 | retried only because every request here is a read. |
| 438 | - An id seen twice → deduplicated **and counted**; silent dedup hides a |
| 439 | traversal bug. A cycle (a page reachable from itself) is bounded by the |
| 440 | visited set and counted the same way. |
| 441 | |
| 442 | There is no partial return anywhere in the package's surface: a caller |
| 443 | cannot mistake "we got some" for "that is all there is", because "some" |
| 444 | is not a value the package produces. |
| 445 | |
| 446 | **What this still does not guarantee**, said plainly: memoria enumerates |
| 447 | everything reachable from the roots the integration was given. A page |
| 448 | shared with nobody, or in a space the integration cannot see, is not |
| 449 | missing — it was never in scope. The status page says how many roots were |
| 450 | found and names them, so "I expected more" is answerable. |
| 451 | |
| 452 | --- |
| 453 | |
| 454 | ## 7. Ancestry, and the scoring trap in it |
| 455 | |
| 456 | A Notion page's meaning is largely positional: *"Week 1"* under |
| 457 | *"Handbook › Onboarding"* is onboarding week 1; the same page floating |
| 458 | free is nothing. memoria's chunk trail today comes only from ATX headings |
| 459 | inside one document, so a naive import loses all of it. |
| 460 | |
| 461 | Each document therefore carries a **path**: the ancestor titles from the |
| 462 | root down, known from traversal (§6). |
| 463 | |
| 464 | **The trap.** The index has two FTS5 columns scored |
| 465 | `bm25(chunks, 3.0, 1.0)` — heading 3×, body 1× (`index.go:165`). |
| 466 | Prepending the path onto the heading trail puts the workspace's top-level |
| 467 | names into **every chunk of every page beneath them at triple weight**. A |
| 468 | search for *handbook* returns the whole handbook above the page that |
| 469 | answers the question, and it gets worse with depth. The symptom is bad |
| 470 | ranking, not an error, which is the kind that survives. |
| 471 | |
| 472 | **Therefore:** a third FTS column, `path`, scored |
| 473 | `bm25(chunks, 3.0, 1.0, 0.5)`. `chunk.Split` is unchanged; a sibling |
| 474 | `chunk.SplitUnder(path, text)` carries the path without merging it into |
| 475 | the document's own trail. `Store.Ingest` moves to an options struct — |
| 476 | one production call site (`handlers.go:183`) and one dev tool |
| 477 | (`tools/seed/main.go:115`). |
| 478 | |
| 479 | **Ancestry is also SHOWN**, above the title on every search result, not |
| 480 | merely indexed. A result carrying only its in-page heading trail leaves |
| 481 | two "Week one" pages under different handbooks indistinguishable to the |
| 482 | reader (§1.2). |
| 483 | |
| 484 | **The 0.5 is a guess**, chosen as half the body's weight because nothing |
| 485 | has measured it, and the code says so where the constant lives. §15 keeps |
| 486 | it open. What is *not* a guess is that the gate under it must actually |
| 487 | fail when the weighting is wrong — see §13 N3, which is where draft 1's |
| 488 | version fell down. |
| 489 | |
| 490 | --- |
| 491 | |
| 492 | ## 8. Data |
| 493 | |
| 494 | Additive-only migrations, per AGENTS.md. |
| 495 | |
| 496 | **`Connector`** — `kind` (`"notion"`), `sealed_token` |
| 497 | (`PurposeCredential`), `workspace_print`, `last_verified_at`, |
| 498 | timestamps. |
| 499 | |
| 500 | **`SyncRun`** — `connector_id`, `state` |
| 501 | (`enumerating|fetching|done|failed|interrupted`), `roots`, `total`, |
| 502 | `fetched`, `skipped`, `empty`, `failed_pages`, `unread_blocks`, |
| 503 | `error_code`, `heartbeat_at`, timestamps. `total` is `-1` until |
| 504 | enumeration completes, following `handlers.go:62`'s sentinel — a total |
| 505 | that could not be determined is not zero pages. |
| 506 | |
| 507 | **`error_code` is a code, never a message**, and Notion supplies the |
| 508 | concrete reason: a 404 body reads *"Could not find database with ID: |
| 509 | be907abe-… Make sure the relevant pages and databases are shared with |
| 510 | your connection \"My Connection\""* — a page id and a connection name, |
| 511 | one `%w` away from a log line. The closed set is `unauthorized`, |
| 512 | `rate_limited`, `traversal_failed`, `extract_failed`, `network`, |
| 513 | `interrupted`; the UI maps it to copy as `flashFor` already does. |
| 514 | |
| 515 | **`NotionPage`** — the ledger, keyed per **connector**, not per run, |
| 516 | which is what makes a second run resume rather than restart: |
| 517 | |
| 518 | | Column | Why | |
| 519 | |---|---| |
| 520 | | `page_print` | keyed page id — lookup, and joins to `Document.ExternalID` | |
| 521 | | `sealed_page_id` | the id itself, sealed under `PurposeCoordinate`. **Reversible**, because the fetch phase needs the id back (§1.1 finding 1) | |
| 522 | | `sealed_path` | the ancestor trail at enumeration time | |
| 523 | | `path_print` | keyed path — lets a move or an ancestor rename be detected without opening the row | |
| 524 | | `seen_edited_time` | what the last enumeration saw | |
| 525 | | `ingested_edited_time` | what memoria actually ingested. **Two columns, because one compares against itself** (§1.1) | |
| 526 | | `state` | `pending\|fetched\|empty\|failed` | |
| 527 | | `error_code` | per row, so one bad page is not a whole bad run | |
| 528 | | `last_seen_at` | §3's mitigation | |
| 529 | | `document_id` | the row it produced | |
| 530 | |
| 531 | **`Document.SealedPath`** under a new `seal.PurposePath`. A path is |
| 532 | content: *"Acquisitions › Project Bluebird"* names a thing whether or not |
| 533 | anyone opens the page. Sealing it under the title's purpose would be |
| 534 | cheaper and is refused — one HKDF purpose per kind of thing sealed. |
| 535 | |
| 536 | New purposes: `PurposePath`, `PurposeCredential`, `PurposeCoordinate`. |
| 537 | |
| 538 | No request body is bound onto a GORM model; the connector form goes |
| 539 | through an explicit `map[string]any` and a `.Select` allowlist. Neither |
| 540 | `scope.Owned` nor `scope.OwnedBy` appears — these rows are team-shared, |
| 541 | which is AGENTS.md's first mechanical rule. |
| 542 | |
| 543 | --- |
| 544 | |
| 545 | ## 9. The run loop, and the sleep it cannot hide |
| 546 | |
| 547 | An owner posts to `/connectors/notion/sync`, **mounted inside the |
| 548 | `csrf.Protect` + `requireSession` group** (`app.go:51,60`) — a route on |
| 549 | the outer mux bypasses both, as `/static/` does. The handler starts the |
| 550 | run and redirects to a status page that refreshes with |
| 551 | `<meta http-equiv="refresh">`. Scriptless, per AGENTS.md. |
| 552 | |
| 553 | **One pass, checkpointed per page.** Draft 2 specified two phases, |
| 554 | enumerate then fetch. With traversal they are the same API call — |
| 555 | fetching a page returns its body *and* the child pages beneath it — so |
| 556 | separating them would double every request against a limit of three per |
| 557 | second (§1.2). |
| 558 | |
| 559 | The loop walks from the roots (§6); for each page it writes the ledger |
| 560 | row first (coordinate, ancestry, `seen_edited`, `last_seen`), then |
| 561 | fetches, then ingests if §10 says the page needs work, then records the |
| 562 | outcome (`state`, `ingested_edited`, `ingested_path`). |
| 563 | |
| 564 | Writing the row BEFORE the fetch is deliberate: a page that then fails to |
| 565 | read has somewhere to be named, instead of being absent. |
| 566 | |
| 567 | **What an interruption actually costs.** The ingest work already done |
| 568 | survives — a resumed run skips every page that is unchanged. The |
| 569 | traversal is repeated, because children cannot be discovered without |
| 570 | asking for them, and a child's own edit does not move its parent's edit |
| 571 | time. So a resume is cheap in the expensive currency (chunking, sealing, |
| 572 | indexing) and not free in API calls. |
| 573 | |
| 574 | **Single-flight, and the deadlock draft 1 shipped.** A run holds the |
| 575 | connector via a conditional update. Draft 1 called the lock's survival |
| 576 | across a restart a feature; it is the bug. The goroutine dies when CARLOS |
| 577 | sleeps the process, having written nothing, and the row stays `fetching` |
| 578 | — so every later sync is refused as "already running", permanently, on |
| 579 | the *normal* path. Two mechanisms fix it: |
| 580 | |
| 581 | - **At boot, any run in `enumerating` or `fetching` becomes |
| 582 | `interrupted`.** There can be no live run at boot, so this is sound by |
| 583 | construction rather than by timeout. |
| 584 | - **A `heartbeat_at` written every N pages**, so a run wedged inside a |
| 585 | *live* process is reclaimable after a stated interval rather than |
| 586 | never. |
| 587 | |
| 588 | **What the sleep costs, said out loud.** CARLOS sleeps an idle app; a |
| 589 | sleeping app syncs nothing. At ~3 requests/second a large workspace is |
| 590 | tens of minutes, so runs will be interrupted. The status page says the |
| 591 | sync progresses while the page is open and resumes where it stopped. |
| 592 | |
| 593 | And the honest naming, which draft 1 fudged: this is **a request-spawned |
| 594 | goroutine that runs until the platform sleeps the process, kept alive by |
| 595 | an open status tab** — not a background worker, and `context.WithoutCancel` |
| 596 | here is not the same thing as `store.go:189`'s bounded post-commit index |
| 597 | refresh. It also sits against parent §3.5's "memoria hibernates": an open |
| 598 | status tab deliberately prevents that, for as long as it is open. §15 |
| 599 | records the scheduler that would replace it. |
| 600 | |
| 601 | --- |
| 602 | |
| 603 | ## 10. Re-ingest: edits, renames, and moves |
| 604 | |
| 605 | Draft 1 handled only edits, and silently. Three conditions mark a ledger |
| 606 | row as needing work: |
| 607 | |
| 608 | 1. `seen_edited_time != ingested_edited_time` — the page changed. |
| 609 | 2. `path_print` changed — the page moved, **or an ancestor was renamed**. |
| 610 | An ancestor rename does not touch a descendant's `last_edited_time` at |
| 611 | all, so without this the descendants keep a stale path forever. |
| 612 | 3. `state` is `pending` or `failed`. |
| 613 | |
| 614 | And in the store: `Ingest`'s unchanged-body short-circuit |
| 615 | (`store.go:113`) currently returns without touching `sealed_title`, so a |
| 616 | renamed page keeps its old title indefinitely. It must **update title and |
| 617 | path when only those changed, without bumping the revision** — a revision |
| 618 | that moves without the content moving would make every cached |
| 619 | authorization stale for no reason, which is the comment already at that |
| 620 | line, and it stays true. |
| 621 | |
| 622 | --- |
| 623 | |
| 624 | ## 11. Egress, and the gate that forbids it |
| 625 | |
| 626 | `internal/memoriatest/gates_test.go:92`'s |
| 627 | `TestNoOutboundNetworkInAppCode` walks `internal/memoria/**` and `cmd/**` |
| 628 | and fails on `*http.Client`, `http.NewRequest`, `net.Dial` and more. It is |
| 629 | slice 0's enforcement of the parent's AI-off floor. **A Notion client |
| 630 | trips it on its first line, and draft 1 did not mention it** — leaving |
| 631 | the implementer's cheapest move to be widening the gate, with no rule for |
| 632 | how far. |
| 633 | |
| 634 | The rule: **the gate is narrowed to something stronger, never deleted.** |
| 635 | |
| 636 | - The source grep keeps its blanket ban over `internal/memoria/**` and |
| 637 | `cmd/**`, with exactly one exemption: `internal/memoria/notion`. |
| 638 | - That exemption is paid for by a **runtime** guard, which is stronger |
| 639 | than the grep it replaces there. The package's client is built on a |
| 640 | transport that **refuses any host but `api.notion.com`** and **refuses |
| 641 | to follow redirects at all**. Both matter: the token travels in an |
| 642 | `Authorization` header, and while Go drops that header across hosts, a |
| 643 | *same-host* redirect keeps it, and a base URL built from stored input |
| 644 | would send it anywhere. The base URL is a constant; nothing |
| 645 | configurable feeds it. |
| 646 | - Gate N8 asserts both, and gate N9 asserts no *other* package acquired a |
| 647 | client. |
| 648 | |
| 649 | **Leak list additions** to parent §3.2: |
| 650 | |
| 651 | - memoria now holds a Notion token, sealed under its own purpose. The |
| 652 | parent's live-host entry already names "§5.1's connector credentials"; |
| 653 | this makes it real rather than prospective. |
| 654 | - memoria now dials `api.notion.com`, so Notion learns the instance's |
| 655 | egress address and sync cadence. No content of the team's goes *out* — |
| 656 | the traffic is Notion's own content coming in — but the parent lists |
| 657 | the sign-in dial for carrying no content either, and the same standard |
| 658 | applies. |
| 659 | - The ledger leaks a page count and an edit-time distribution from the |
| 660 | bucket. |
| 661 | |
| 662 | The AI-off floor is unchanged: with the assistant off, no document of the |
| 663 | team's leaves the instance, at ingest or at query. |
| 664 | |
| 665 | --- |
| 666 | |
| 667 | ## 12. Credentials, privilege, and surfaces |
| 668 | |
| 669 | **The token** is a Notion internal integration token, pasted by an owner, |
| 670 | sealed under `PurposeCredential`, never rendered back — not even masked, |
| 671 | because a masked secret is still a secret in the DOM. Verified on save |
| 672 | with one `/v1/users/me` call, so a bad token fails at the form rather |
| 673 | than mid-run. Rotation is replacing the row through a form on |
| 674 | the connector page; there is no key history, because a replaced token has |
| 675 | no use. **Disconnect removes the row** — |
| 676 | parent §3.6 promises "disconnecting" and draft 1 gave no affordance for |
| 677 | it. Disconnecting deletes the credential and stops future syncs; by |
| 678 | decision 3 it deletes no documents, and the confirmation says so in those |
| 679 | words. |
| 680 | |
| 681 | **Least privilege** (parent §3.5, "a requirement, not a nicety"): a |
| 682 | read-only internal integration with no user information, shared with the |
| 683 | specific pages to import rather than the workspace root. The setup copy |
| 684 | says this because the connector cannot enforce it — Notion decides what a |
| 685 | token can do, and memoria can only ask for the right one and refuse to |
| 686 | pretend otherwise. |
| 687 | |
| 688 | **"Owner" is `Config.Owners`, and in slice 0 that is the whole admitted |
| 689 | set.** Draft 1 wrote "owners only" as though it were a restriction; it |
| 690 | is not one today, and stating it as a gate while it gates nothing is the |
| 691 | pattern AGENTS.md opens with. So: the routes are guarded by an explicit |
| 692 | `requireOwner` that reads the session identity and checks |
| 693 | `Config.Owners`; it is *currently equivalent* to `requireSession` and the |
| 694 | code says so at the definition. To make it testable at all, |
| 695 | **`AppForTest` grows an injectable identity** — today it bypasses |
| 696 | `RequireSession` entirely, so no identity reaches the context and no such |
| 697 | gate could be written. §15 records that slice 1, which replaces the |
| 698 | owners list with S7, must supply an owner predicate or these routes lose |
| 699 | their guard. |
| 700 | |
| 701 | **Surfaces.** `/connectors` lists connectors; `/connectors/notion` is |
| 702 | setup, status and history. Scriptless, attribute grammar, colour tokens, |
| 703 | `theme.css` linked. Three things the interface must say, each a place |
| 704 | this design could mislead: |
| 705 | |
| 706 | 1. **"memoria keeps what it imports."** On the setup page, before the |
| 707 | token is entered — parent §3.6 has the wording. |
| 708 | 2. **Interrupted is not progress, and a running total is not a total.** |
| 709 | A run stopped by a sleep says so and offers resume. A run still going |
| 710 | reports how many pages it has SEEN so far and states that the total is |
| 711 | not known until it finishes — because with one pass (§1.2) the |
| 712 | denominator does not exist until the walk ends. `total` holds `-1` |
| 713 | until then and is never rendered as 0. |
| 714 | 3. **A count that could not be determined says so** — `total` is `-1` |
| 715 | until enumeration finishes, and renders as "counting", never 0. |
| 716 | |
| 717 | Plus the run's honest totals: roots found, pages fetched, skipped, empty, |
| 718 | **failed**, and blocks not understood. Copy goes through the copy-review |
| 719 | gate before it is written to a file. |
| 720 | |
| 721 | --- |
| 722 | |
| 723 | ## 13. Gates |
| 724 | |
| 725 | Every gate is written, then **broken so it goes red, then reverted** — |
| 726 | per path, with the mutation and date recorded in the test comment. |
| 727 | |
| 728 | **N1 — `TestTraversalIncompleteFailsTheRun`.** A fake whose |
| 729 | `blocks/{id}/children` returns `has_more: true` with no `next_cursor`, |
| 730 | and one that 429s past budget. Run ends `failed` with `traversal_failed`; |
| 731 | the status page does not say complete. *Mutation: return accumulated |
| 732 | results with a nil error on retry exhaustion.* |
| 733 | |
| 734 | **N2 — `TestSearchIsNotUsedAsAnEnumerator`.** The traversal must not |
| 735 | depend on search returning descendants: a fake whose `/v1/search` returns |
| 736 | **only the roots** while children exist below must still import every |
| 737 | child. *Mutation: enumerate from search results instead of traversing.* |
| 738 | This is the gate that pins §6's whole reason for existing, and draft 1 |
| 739 | had nothing like it. |
| 740 | |
| 741 | **N3 — `TestAncestryCannotOutrankContent`.** Draft 1's version could not |
| 742 | fail: BM25 sums per-column contributions, so a row matching path *and* |
| 743 | body beats a path-only match under any positive weights. The fixture that |
| 744 | reproduces the real trap: **20 pages under a root titled "Handbook", none |
| 745 | of whose bodies contain "handbook"**, and **one page under "Engineering" |
| 746 | whose body contains it once**. Query "handbook"; the Engineering page must |
| 747 | rank first. *Mutation: move the path into the heading column at 3.0.* |
| 748 | **The flip must be measured and both orderings recorded in the test |
| 749 | comment** — if the mutation does not actually flip the order, the fixture |
| 750 | is wrong and must be strengthened until it does. A gate for a ranking |
| 751 | property is worthless unless the ranking has been observed to change. |
| 752 | |
| 753 | **N4 — `TestTitleOnlyPageIsIngested`.** A page with no blocks becomes a |
| 754 | title-only document, the ledger row is `empty`, and the run counts it. |
| 755 | *Mutation: drop `AllowTitleOnly`, so `Ingest` refuses and the run fails.* |
| 756 | |
| 757 | **N5 — `TestUnreadBlocksAreCountedNotSwallowed`.** *Mutation: drop the |
| 758 | counter increment.* |
| 759 | |
| 760 | **N6 — `TestAFailedPageIsNamedNotAbsent`.** One page of 5 404s. The run |
| 761 | completes, `failed_pages` is 1, the row is `failed` with a code, and the |
| 762 | status page does not read as a complete import. *Mutation: `continue` on |
| 763 | fetch error.* |
| 764 | |
| 765 | **N7 — `TestSyncNeverDeletesOnAbsence`.** Two pages ingested, then a |
| 766 | traversal returning one. Both documents survive and the run is `done` — |
| 767 | absence is not an error either. *Mutation: add the diff-and-delete draft |
| 768 | 1's predecessor proposed.* |
| 769 | |
| 770 | **N8 — `TestNotionClientDialsOnlyNotionAndRefusesRedirects`.** The |
| 771 | transport rejects any other host, and refuses a 302 even to the same |
| 772 | host. *Mutations: change the base URL constant; enable redirects.* |
| 773 | |
| 774 | **N9 — `TestOnlyTheNotionPackageReachesTheNetwork`.** The narrowed source |
| 775 | grep, with the exemption asserted to be exactly one directory. |
| 776 | *Mutation: add an `http.Client` to `internal/memoria/handlers.go`.* |
| 777 | |
| 778 | **N10 — `TestRenameAndMoveReachMemoria`.** Rename a page; sync; the new |
| 779 | title renders. Rename an *ancestor*; sync; the descendant's path updates |
| 780 | though its own `last_edited_time` never changed. *Mutation: compare only |
| 781 | `seen_edited_time`.* |
| 782 | |
| 783 | **N11 — `TestResumeContinuesRatherThanRestarts`** and |
| 784 | **N12 — `TestInterruptedRunDoesNotWedgeTheConnector`.** Mark a run |
| 785 | `fetching`, rebuild the app (a boot), POST sync: a new run starts and the |
| 786 | old is `interrupted`. *Mutations: scope the ledger by run id; remove the |
| 787 | boot-time reclaim.* |
| 788 | |
| 789 | **N13 — `TestNoNotionContentInLogs`** extends gate 4 to the new |
| 790 | packages: no title, body, path, page id or token in any log line, error |
| 791 | string or metric label, observed over a run driven end to end against the |
| 792 | fake — whose error bodies carry a canary title and a canary page id, so |
| 793 | the error path is actually exercised. Parent §13 notes the existing gate |
| 794 | 4's credential half cannot fail; this one is built not to repeat that. |
| 795 | *Mutation: log the page title at Info in the fetch loop.* |
| 796 | |
| 797 | **N14 — `TestTokenIsNeverRendered`.** With a token stored, the connector |
| 798 | page's HTML contains neither the whole token nor any 8-character run of |
| 799 | it. (Draft 1 said "no substring", which every single character satisfies.) |
| 800 | *Mutation: render it into the input's `value`.* |
| 801 | |
| 802 | **N15 — `TestNotionVersionIsPinned`.** The client sends |
| 803 | `Notion-Version: 2026-03-11`. *Mutation: drop the header.* |
| 804 | |
| 805 | **And the run is looked at in a browser** via `tools/drive`, per |
| 806 | AGENTS.md — which found three bugs in its first use that the whole Go |
| 807 | suite passed over. A template test proves markup exists, not that a |
| 808 | sync's progress reads sensibly at 40%. |
| 809 | |
| 810 | --- |
| 811 | |
| 812 | ## 14. Sequencing: three stages, not one |
| 813 | |
| 814 | The review's last finding, and it is right — draft 1 was three pieces of |
| 815 | work wearing one spec. |
| 816 | |
| 817 | **Stage 1 — ranking.** The third FTS column, `SplitUnder`, the `Ingest` |
| 818 | options struct, `SealedPath`, `AllowTitleOnly`. No Notion code at all. |
| 819 | This re-ranks **every existing document**, so it lands and is reviewed on |
| 820 | its own, with N3's flip measured before anything depends on it. |
| 821 | |
| 822 | **Stage 2 — the client.** `internal/memoria/notion`: transport, host |
| 823 | pin, total decoder, traversal, Markdown extraction, the fake server. Pure, |
| 824 | no DB, no app types. Gates N1, N2, N8, N9, N15. |
| 825 | |
| 826 | **Stage 3 — the connector.** Credential, ledger, run loop, boot reclaim, |
| 827 | surfaces, copy. Gates N4–N7, N10–N14. |
| 828 | |
| 829 | --- |
| 830 | |
| 831 | ## 15. Open |
| 832 | |
| 833 | - **memoria cannot replace Notion yet, and this connector does not change |
| 834 | that.** It has no editing surface. This moves the corpus, which has to |
| 835 | happen first under any plan, but until someone can write in memoria, |
| 836 | Notion remains where new pages are made. Recorded so the migration is |
| 837 | not mistaken for the replacement. |
| 838 | - **No real workspace has been touched.** Every test runs against a fake |
| 839 | built from the documentation. AGENTS.md's first broken rule is about |
| 840 | guards that cannot fail for the thing they name, and a connector |
| 841 | verified only against fixtures its author also wrote is that risk in a |
| 842 | different costume — the review agreed and asked for it to be promoted. |
| 843 | **A run against the real workspace is a ship requirement**, owned by |
| 844 | Paul, blocking stage 3's merge and nothing earlier. |
| 845 | - **The 0.5 path weight is unmeasured** (§7), and N3 measures only that |
| 846 | the ordering flips, not that 0.5 is the right number. Tune against a |
| 847 | real workspace and a real query set. |
| 848 | - **Database rows as documents may be noise** (§5). One look at a real |
| 849 | import settles it. |
| 850 | - **The scheduler.** §9's open status tab is a workaround for having no |
| 851 | background worker, and it defeats hibernation while open. Whether |
| 852 | CARLOS offers a scheduler is parent §13's open question about scratch |
| 853 | storage in a second costume; if it does, decision 8 changes. |
| 854 | - **Slice 1 must supply an owner predicate** (§12), or `/connectors` |
| 855 | loses its guard when the owners list is replaced by S7. |
| 856 | - **Listing what is no longer in Notion.** Decision 3 keeps such |
| 857 | documents and §3 stores `last_seen_at` so the view is buildable; the |
| 858 | view itself is not built. |
| 859 | - **Review pass on draft 2.** Draft 1: `claude-fable-5-1`, REQUEST |
| 860 | CHANGES, 15 findings, §1.1. Draft 2 is unreviewed. |
| 861 | |