oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1# 🤖 memoria — Notion ingest
2
3**Status:** draft 2. Draft 1 reviewed by `claude-fable-5-1`, REQUEST
4CHANGES, 15 findings — §1.1 records what it changed, including three
5blockers and one finding that removed the enumeration design entirely.
6**Parent:** `2026-09-03-memoria-design.md`. Where this disagrees with the
7parent, the parent wins and this document is wrong — fix it in the same
8commit. §3 does exactly that: it changes the parent, and says so.
9
10This is the first half of the parent's **slice 3** (§11, external
11connectors), under the parent's §4.2: content memoria owns outright,
12because there is no sibling to ask.
13
14---
15
16## 0. The one rule, as it applies here
17
18The parent's rule is *memoria never speaks a passage without asking
19whoever owns it whether this person may see it, right now.* For Notion
20there is nobody to ask, and the parent already says so (§4.2): **memoria
21is the app of record for what it ingests from Notion.** The sharing act
22at the far end was the publish. §12 makes sure the product says this
23rather than implying otherwise.
24
25The second rule is unchanged, and this document adds to what it covers: a
26Notion page's **title, body, headings and ancestry path are all content**
27and all sealed. Its page id is a coordinate — sealed reversibly, and
28separately fingerprinted for lookup (§8, and §1.1 finding 1 for why draft
291'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
50Recorded rather than quietly folded in, in the parent's §1.2–§1.4 style.
51Findings 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
124Draft 2 was written before the code. Five things did not survive contact,
125and 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
158Two smaller ones: `tools/drive` wrote to a hardcoded absolute path under
159one person's home directory and panicked anywhere else, now the OS temp
160dir; and the connector page ordered token rotation above import status,
161which put a rare housekeeping act between the reader and the thing the
162page is for.
163
164
165### 1.3 What the Fable code review changed
166
167The branch was reviewed by `claude-fable-5-1` before merge — REQUEST
168CHANGES, 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
220stages landed on one branch and were reviewed together. Recorded rather
221than quietly diverged from — the cost was a larger review than the design
222intended, and the review found a blocker that a stage-2-only review would
223have found sooner.
224
225**§15's ship requirement is still unmet.** No run against a real Notion
226workspace has happened. Finding 1 above is exactly the failure §15
227predicts from "a connector verified only against fixtures its author also
228wrote": it was found by a reviewer reading Notion's documentation, not by
229a test. That requirement stands.
230
231---
232
233## 2. Why this is unblocked, when the parent sequenced it fourth
234
235The parent's §11 puts external connectors in slice 3, behind the sibling
236contract (slice 1) — which parent §13 records as "blocked on agreement,
237not on code", in *other repositories*. That ordering was never a
238dependency. Slice 3's stated requirements are least-privilege credentials
239(parent §3.5) and gate 7, both local. Notion content is memoria-owned
240under §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
248Draft 1 said "Notion sits on slice 0 and nothing else", which the review
249corrected: 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
252dependencies. 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
259only 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
262per-page control, and a second list to keep in step with a list is the
263shape 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
277says "§5.1's enumeration diff is a convenience that eventually notices
278upstream deletions", and parent §5.1's Notion row records `Delete: diff
279only`. Draft 1 removed the diff and cited parent §3.6's README paragraph
280as 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
282single page archived while the connection stays live. The review was
283right to call that smuggled.
284
285**The argument for the change, made properly.** memoria is being adopted
286as the replacement for Notion, not as a mirror of it. Under a
287replacement, archiving a page in Notion *after* migrating it is the
288intended workflow — and a diff-and-delete connector reads that as
289"forget it", deleting from the app of record the document the migration
290existed to preserve. A convenience that eventually notices deletions is
291the wrong convenience when deletion upstream is the goal.
292
293**What it costs, and the mitigation.** A page deleted upstream *because
294it should not have been shared* stays indexed and readable by every
295admitted person, with no notice. That is a real cost and §12 puts it in
296the interface. Every ledger row therefore carries **`last_seen_at`**,
297written on every enumeration — it costs nothing, since traversal already
298touches every row — so the "no longer in Notion" view is buildable later
299without a migration. Decision 3 keeps the documents; it does not license
300being unable to list them.
301
302**Parent amendments required in this commit:** §4.2's "eventually notices
303upstream deletions" and §5.1's Notion `Delete` cell. Not doing so leaves
304two specs that contradict, which is the drift CLAUDE.md exists to
305prevent.
306
307---
308
309## 4. The Notion object model we are coding against
310
311Pinned at **`Notion-Version: 2026-03-11`**, a constant in the client with
312a gate asserting it. This matters more than a pin usually does: version
313`2025-09-03` changed the object model incompatibly. A **database** now
314contains 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
319pre-2025 model and listed pinning as an *open item*; it is a decision
320with consequences for every parent link the ancestry walk reads.
321
322Trashed content is skipped: `in_trash: true` is not ingested, and an
323already-ingested page that goes to trash is an absence, which by decision
3243 means nothing happens.
325
326---
327
328## 5. What a Notion page becomes
329
330One 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
335then children), `code` (fenced, language preserved), `divider`, `table`
336(pipe table), `image`/`file`/`video`/`pdf`/`meeting_notes` (caption and
337filename only — memoria indexes text), and `child_page`/`child_database`
338as a one-line reference, **never inlined**: they are their own documents
339and inlining would duplicate every passage into all of its ancestors.
340Rich text uses Notion's `plain_text`, so an inline equation or mention
341degrades to the text Notion itself renders.
342
343**Unknown block types produce nothing and increment a counter.** Not an
344error — Notion adds block types, and a connector that fails a run on an
345unrecognised one breaks on Notion's release schedule. But not silence
346either: the run reports "N blocks of a type memoria does not read", so a
347page that came in thin has a reason attached rather than looking like a
348thin page.
349
350**Empty pages are ingested as title-only documents.** Draft 1 said an
351empty page "ingests as an empty body"; `store.go:73` and `:101` refuse
352exactly that, so the gate asserted something the store forbids. Real
353workspaces are full of placeholders and title-only rows, and both
354available alternatives were wrong: failing the run lets one placeholder
355block the whole import, and skipping makes the page an absence with no
356row to explain it. So `Store.Ingest` grows an explicit
357`AllowTitleOnly` option — off for uploads and paste, where an empty
358submission is a user error worth refusing, on for connectors, where it is
359a fact about the source. The document's body is its title; it chunks to
360one 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
363becomes one, with properties rendered as a leading key/value block:
364
365```
366Status: Shipped
367Owner: Ana
368Due: 2026-03-01
369
370<the row page's blocks>
371```
372
373`relation` renders the related pages' titles, `rollup` and `formula`
374their computed value. **This is the part most likely to be wrong** — a
37540-row database becomes 40 one-paragraph documents, which may be noise
376rather than memory. §15 records it as unanswerable without a real
377workspace.
378
379**Title** is the page's title property. A database row with an empty
380title gets its data source's name plus its first non-empty property,
381because `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
385moves, which is what `Document.ExternalID`'s contract requires. It is
386keyed 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
394cannot be done.** Notion's documentation is explicit that search "is not
395optimized for … exhaustively enumerating through all the documents that a
396bot has access to", that **"search is not guaranteed to return
397everything"**, and that its index is eventually consistent. Crucially
398there is *no field in the response that says the result was partial*. So
399a run that pages cleanly to `has_more: false`, with no 429 and no error,
400may simply be missing pages — and would report a complete import. No
401decoder, however total, can detect it.
402
403The 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
408So search is reliable for exactly one thing, and that is what we use it
409for:
410
4111. **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.
4142. **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
422parent, 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
424run, had to hop `block_id` parents for inline child pages, and truncated
425silently 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
428actually 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
442There is no partial return anywhere in the package's surface: a caller
443cannot mistake "we got some" for "that is all there is", because "some"
444is not a value the package produces.
445
446**What this still does not guarantee**, said plainly: memoria enumerates
447everything reachable from the roots the integration was given. A page
448shared with nobody, or in a space the integration cannot see, is not
449missing — it was never in scope. The status page says how many roots were
450found and names them, so "I expected more" is answerable.
451
452---
453
454## 7. Ancestry, and the scoring trap in it
455
456A Notion page's meaning is largely positional: *"Week 1"* under
457*"Handbook › Onboarding"* is onboarding week 1; the same page floating
458free is nothing. memoria's chunk trail today comes only from ATX headings
459inside one document, so a naive import loses all of it.
460
461Each document therefore carries a **path**: the ancestor titles from the
462root 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`).
466Prepending the path onto the heading trail puts the workspace's top-level
467names into **every chunk of every page beneath them at triple weight**. A
468search for *handbook* returns the whole handbook above the page that
469answers the question, and it gets worse with depth. The symptom is bad
470ranking, 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
475the document's own trail. `Store.Ingest` moves to an options struct —
476one 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
480merely indexed. A result carrying only its in-page heading trail leaves
481two "Week one" pages under different handbooks indistinguishable to the
482reader (§1.2).
483
484**The 0.5 is a guess**, chosen as half the body's weight because nothing
485has measured it, and the code says so where the constant lives. §15 keeps
486it open. What is *not* a guess is that the gate under it must actually
487fail when the weighting is wrong — see §13 N3, which is where draft 1's
488version fell down.
489
490---
491
492## 8. Data
493
494Additive-only migrations, per AGENTS.md.
495
496**`Connector`** — `kind` (`"notion"`), `sealed_token`
497(`PurposeCredential`), `workspace_print`, `last_verified_at`,
498timestamps.
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
504enumeration completes, following `handlers.go:62`'s sentinel — a total
505that could not be determined is not zero pages.
506
507**`error_code` is a code, never a message**, and Notion supplies the
508concrete reason: a 404 body reads *"Could not find database with ID:
509be907abe-… Make sure the relevant pages and databases are shared with
510your connection \"My Connection\""* — a page id and a connection name,
511one `%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,
516which 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
532content: *"Acquisitions › Project Bluebird"* names a thing whether or not
533anyone opens the page. Sealing it under the title's purpose would be
534cheaper and is refused — one HKDF purpose per kind of thing sealed.
535
536New purposes: `PurposePath`, `PurposeCredential`, `PurposeCoordinate`.
537
538No request body is bound onto a GORM model; the connector form goes
539through an explicit `map[string]any` and a `.Select` allowlist. Neither
540`scope.Owned` nor `scope.OwnedBy` appears — these rows are team-shared,
541which is AGENTS.md's first mechanical rule.
542
543---
544
545## 9. The run loop, and the sleep it cannot hide
546
547An owner posts to `/connectors/notion/sync`, **mounted inside the
548`csrf.Protect` + `requireSession` group** (`app.go:51,60`) — a route on
549the outer mux bypasses both, as `/static/` does. The handler starts the
550run 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,
554enumerate then fetch. With traversal they are the same API call —
555fetching a page returns its body *and* the child pages beneath it — so
556separating them would double every request against a limit of three per
557second (§1.2).
558
559The loop walks from the roots (§6); for each page it writes the ledger
560row first (coordinate, ancestry, `seen_edited`, `last_seen`), then
561fetches, then ingests if §10 says the page needs work, then records the
562outcome (`state`, `ingested_edited`, `ingested_path`).
563
564Writing the row BEFORE the fetch is deliberate: a page that then fails to
565read has somewhere to be named, instead of being absent.
566
567**What an interruption actually costs.** The ingest work already done
568survives — a resumed run skips every page that is unchanged. The
569traversal is repeated, because children cannot be discovered without
570asking for them, and a child's own edit does not move its parent's edit
571time. So a resume is cheap in the expensive currency (chunking, sealing,
572indexing) and not free in API calls.
573
574**Single-flight, and the deadlock draft 1 shipped.** A run holds the
575connector via a conditional update. Draft 1 called the lock's survival
576across a restart a feature; it is the bug. The goroutine dies when CARLOS
577sleeps the process, having written nothing, and the row stays `fetching`
578— so every later sync is refused as "already running", permanently, on
579the *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
589sleeping app syncs nothing. At ~3 requests/second a large workspace is
590tens of minutes, so runs will be interrupted. The status page says the
591sync progresses while the page is open and resumes where it stopped.
592
593And the honest naming, which draft 1 fudged: this is **a request-spawned
594goroutine that runs until the platform sleeps the process, kept alive by
595an open status tab** — not a background worker, and `context.WithoutCancel`
596here is not the same thing as `store.go:189`'s bounded post-commit index
597refresh. It also sits against parent §3.5's "memoria hibernates": an open
598status tab deliberately prevents that, for as long as it is open. §15
599records the scheduler that would replace it.
600
601---
602
603## 10. Re-ingest: edits, renames, and moves
604
605Draft 1 handled only edits, and silently. Three conditions mark a ledger
606row as needing work:
607
6081. `seen_edited_time != ingested_edited_time` — the page changed.
6092. `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.
6123. `state` is `pending` or `failed`.
613
614And in the store: `Ingest`'s unchanged-body short-circuit
615(`store.go:113`) currently returns without touching `sealed_title`, so a
616renamed page keeps its old title indefinitely. It must **update title and
617path when only those changed, without bumping the revision** — a revision
618that moves without the content moving would make every cached
619authorization stale for no reason, which is the comment already at that
620line, 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/**`
628and fails on `*http.Client`, `http.NewRequest`, `net.Dial` and more. It is
629slice 0's enforcement of the parent's AI-off floor. **A Notion client
630trips it on its first line, and draft 1 did not mention it** — leaving
631the implementer's cheapest move to be widening the gate, with no rule for
632how far.
633
634The 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
662The AI-off floor is unchanged: with the assistant off, no document of the
663team'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,
670sealed under `PurposeCredential`, never rendered back — not even masked,
671because a masked secret is still a secret in the DOM. Verified on save
672with one `/v1/users/me` call, so a bad token fails at the form rather
673than mid-run. Rotation is replacing the row through a form on
674the connector page; there is no key history, because a replaced token has
675no use. **Disconnect removes the row** —
676parent §3.6 promises "disconnecting" and draft 1 gave no affordance for
677it. Disconnecting deletes the credential and stops future syncs; by
678decision 3 it deletes no documents, and the confirmation says so in those
679words.
680
681**Least privilege** (parent §3.5, "a requirement, not a nicety"): a
682read-only internal integration with no user information, shared with the
683specific pages to import rather than the workspace root. The setup copy
684says this because the connector cannot enforce it — Notion decides what a
685token can do, and memoria can only ask for the right one and refuse to
686pretend otherwise.
687
688**"Owner" is `Config.Owners`, and in slice 0 that is the whole admitted
689set.** Draft 1 wrote "owners only" as though it were a restriction; it
690is not one today, and stating it as a gate while it gates nothing is the
691pattern 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
694code 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
697gate could be written. §15 records that slice 1, which replaces the
698owners list with S7, must supply an owner predicate or these routes lose
699their guard.
700
701**Surfaces.** `/connectors` lists connectors; `/connectors/notion` is
702setup, status and history. Scriptless, attribute grammar, colour tokens,
703`theme.css` linked. Three things the interface must say, each a place
704this design could mislead:
705
7061. **"memoria keeps what it imports."** On the setup page, before the
707 token is entered — parent §3.6 has the wording.
7082. **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.
7143. **A count that could not be determined says so** — `total` is `-1`
715 until enumeration finishes, and renders as "counting", never 0.
716
717Plus the run's honest totals: roots found, pages fetched, skipped, empty,
718**failed**, and blocks not understood. Copy goes through the copy-review
719gate before it is written to a file.
720
721---
722
723## 13. Gates
724
725Every gate is written, then **broken so it goes red, then reverted** —
726per 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`,
730and one that 429s past budget. Run ends `failed` with `traversal_failed`;
731the status page does not say complete. *Mutation: return accumulated
732results with a nil error on retry exhaustion.*
733
734**N2 — `TestSearchIsNotUsedAsAnEnumerator`.** The traversal must not
735depend on search returning descendants: a fake whose `/v1/search` returns
736**only the roots** while children exist below must still import every
737child. *Mutation: enumerate from search results instead of traversing.*
738This is the gate that pins §6's whole reason for existing, and draft 1
739had nothing like it.
740
741**N3 — `TestAncestryCannotOutrankContent`.** Draft 1's version could not
742fail: BM25 sums per-column contributions, so a row matching path *and*
743body beats a path-only match under any positive weights. The fixture that
744reproduces the real trap: **20 pages under a root titled "Handbook", none
745of whose bodies contain "handbook"**, and **one page under "Engineering"
746whose body contains it once**. Query "handbook"; the Engineering page must
747rank 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
749comment** — if the mutation does not actually flip the order, the fixture
750is wrong and must be strengthened until it does. A gate for a ranking
751property is worthless unless the ranking has been observed to change.
752
753**N4 — `TestTitleOnlyPageIsIngested`.** A page with no blocks becomes a
754title-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
758counter increment.*
759
760**N6 — `TestAFailedPageIsNamedNotAbsent`.** One page of 5 404s. The run
761completes, `failed_pages` is 1, the row is `failed` with a code, and the
762status page does not read as a complete import. *Mutation: `continue` on
763fetch error.*
764
765**N7 — `TestSyncNeverDeletesOnAbsence`.** Two pages ingested, then a
766traversal returning one. Both documents survive and the run is `done` —
767absence is not an error either. *Mutation: add the diff-and-delete draft
7681's predecessor proposed.*
769
770**N8 — `TestNotionClientDialsOnlyNotionAndRefusesRedirects`.** The
771transport rejects any other host, and refuses a 302 even to the same
772host. *Mutations: change the base URL constant; enable redirects.*
773
774**N9 — `TestOnlyTheNotionPackageReachesTheNetwork`.** The narrowed source
775grep, 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
779title renders. Rename an *ancestor*; sync; the descendant's path updates
780though 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
786old is `interrupted`. *Mutations: scope the ledger by run id; remove the
787boot-time reclaim.*
788
789**N13 — `TestNoNotionContentInLogs`** extends gate 4 to the new
790packages: no title, body, path, page id or token in any log line, error
791string or metric label, observed over a run driven end to end against the
792fake — whose error bodies carry a canary title and a canary page id, so
793the error path is actually exercised. Parent §13 notes the existing gate
7944'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
798page's HTML contains neither the whole token nor any 8-character run of
799it. (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
806AGENTS.md — which found three bugs in its first use that the whole Go
807suite passed over. A template test proves markup exists, not that a
808sync's progress reads sensibly at 40%.
809
810---
811
812## 14. Sequencing: three stages, not one
813
814The review's last finding, and it is right — draft 1 was three pieces of
815work wearing one spec.
816
817**Stage 1 — ranking.** The third FTS column, `SplitUnder`, the `Ingest`
818options struct, `SealedPath`, `AllowTitleOnly`. No Notion code at all.
819This re-ranks **every existing document**, so it lands and is reviewed on
820its own, with N3's flip measured before anything depends on it.
821
822**Stage 2 — the client.** `internal/memoria/notion`: transport, host
823pin, total decoder, traversal, Markdown extraction, the fake server. Pure,
824no DB, no app types. Gates N1, N2, N8, N9, N15.
825
826**Stage 3 — the connector.** Credential, ledger, run loop, boot reclaim,
827surfaces, 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