oficina / memoria Public
π€ memoria β Notion ingest
Status: draft 2. Draft 1 reviewed by claude-fable-5-1, REQUEST CHANGES, 15 findings β Β§1.1 records what it changed, including three blockers and one finding that removed the enumeration design entirely. Parent: 2026-09-03-memoria-design.md. Where this disagrees with the parent, the parent wins and this document is wrong β fix it in the same commit. Β§3 does exactly that: it changes the parent, and says so.
This is the first half of the parent's slice 3 (Β§11, external connectors), under the parent's Β§4.2: content memoria owns outright, because there is no sibling to ask.
---
0. The one rule, as it applies here
The parent's rule is memoria never speaks a passage without asking whoever owns it whether this person may see it, right now. For Notion there is nobody to ask, and the parent already says so (Β§4.2): memoria is the app of record for what it ingests from Notion. The sharing act at the far end was the publish. Β§12 makes sure the product says this rather than implying otherwise.
The second rule is unchanged, and this document adds to what it covers: a Notion page's title, body, headings and ancestry path are all content and all sealed. Its page id is a coordinate β sealed reversibly, and separately fingerprinted for lookup (Β§8, and Β§1.1 finding 1 for why draft 1's version could not work at all).
---
1. Settled decisions
| # | Decision | Because | |---|---|---| | 1 | Notion ships without slices 1 and 2 | Β§4.2 needs no sibling contract, no person ref, no model. Β§2 | | 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 | | 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 | | 4 | One Notion page β one memoria document | The page is the unit people share, name and link to. Β§5 | | 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 | | 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 | | 7 | The credential is sealed under its own HKDF purpose | Parent Β§3.5, verbatim: "so a chunk-store disclosure does not yield them" | | 8 | The run is admin-triggered and resumable, not scheduled | The app sleeps. Β§9 says what that costs and refuses to hide it | | 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 | | 10 | Ship in three stages, ranking first | Stage 1 re-ranks every existing document and deserves its own review. Β§14 |
1.1 What the Fable review changed
Recorded rather than quietly folded in, in the parent's Β§1.2βΒ§1.4 style. Findings 1, 2, 3 were blockers; finding 4 was the expensive one.
- The ledger could not fetch anything (blocker). Draft 1 stored only a keyed page id, and
seal.Fingerprintis HMAC-SHA256 (seal/seal.go:106) β one-way. Phase 2 was specified asGET /v1/blocks/{page_id}/childrenover ledger rows, so a resumed run held 1,930 fingerprints and no URL to call. "Resumption is free and needs no separate mechanism" was true only because the mechanism was absent. Now: a sealed page id (PurposeCoordinate, reversible) plus the print for lookup. - Search cannot enumerate, and there is no signal when it fails to (finding 4, and it removed the design). Draft 1 promised a total decoder: "every enumeration either yields every item or fails." Notion's own documentation says the search endpoint is not optimized for "exhaustively enumerating through all the documents that a bot has access to" and that "search is not guaranteed to return everything", with no field in the response saying so. A clean run to
has_more: falsecan be missing pages, and draft 1 would have reported a complete import. That is the codebase's worst failure shape produced by the choice of endpoint, and gate N1 would have stayed green while the property it named was violated β a guard written to pass, at the architecture level rather than the test level. Β§6 replaces it with traversal. The reviewer proposed arequest_statusfield as the fix; that field is not in the documentation and the real situation is worse than the finding, which is why Β§6 is a redesign and not a decoder patch. - Single-flight wedged the connector on the normal path (blocker). The run is a detached goroutine; when CARLOS sleeps the app mid-fetch the goroutine dies without writing anything and the row stays
fetching. Nothing in draft 1 ever wroteinterrupted, so every later sync was refused as "already running", and Β§12's "interrupted at 412 of 1,930" copy had no writer. Draft 1 described the deadlock as a feature ("survives a restart that left a run marked active"). Now: boot-time reclaim plus a heartbeat, Β§9. - Gate N3 could not go red (finding 7, the best one). FTS5's
bm25()sums per-column contributions, so a row matching in both path and body outranks a path-only match under any positive weights. Draft 1's fixture stayed green with the mutation applied β the gate written to catch Β§7's scoring trap could not catch it. Β§13 specifies the fixture that reproduces the trap and requires the flip to be measured, not assumed. - Renames and moves never reached memoria.
store.go:113short- circuits on an unchanged body print without touchingsealed_title, so a renamed page keeps its old title forever; an ancestor rename does not change a descendant'slast_edited_timeat all, so the fetch phase skipped it beforeIngestwas even called. And the ledger had one edit-time column, so the phase-2 comparison compared it against itself. Β§10. - Empty pages could not be ingested.
store.go:73refuses an empty body; real workspaces are full of title-only pages and placeholders. Draft 1's gate N2 asserted an empty body "ingests" and would have failed before its mutation was applied. Β§5. - A page that failed to fetch had nowhere to be recorded, so three unreadable pages inside 1,930 rendered as a complete import. Β§8 gives every ledger row a state and an error code.
- "Owners only" named a distinction slice 0 does not have.
MEMORIA_OWNERSis the entire admission list (config.go:65,auth.go:28); today "owners" β‘ "signed in". Worse,AppForTestbypassesRequireSession, so no identity reaches the request context and the gate could not be written at all. Β§12. - Decision 3 contradicts the parent, and draft 1 claimed it agreed. Draft 1 cited parent Β§3.6 as proof. Β§3 now argues it as a change and amends the parent in this commit, which is what this document's own header requires.
- Smaller:
Retry-Afterapplies to 529 as well as 429; a Notion 404 body quotes the page id and the connection name, which is a concrete reason for Β§8's code-not-message rule; the sync route must be mounted inside the CSRF and session group;totalneeds the codebase's-1sentinel; there is no disconnect affordance though parent Β§3.6 promises one;context.WithoutCancelinstore.go:189is a bounded post-commit refresh, not a precedent for a multi-minute job.
1.2 What building it changed
Draft 2 was written before the code. Five things did not survive contact, and they are recorded here rather than quietly diverged from.
- One pass, not two phases. Β§9 specified enumerate-then-fetch. With traversal they are the same API call β fetching a page returns its body and the child pages under it β so running them separately would double every request against a limit of three per second. The loop is one pass; the ledger is still written per page.
- Therefore the interruption cost is larger than Β§9 claimed, and the progress display is different. "The cost of an interruption is the page in flight" is false: the ingest work already done survives, but a resumed run repeats the traversal, because a page's children cannot be discovered without asking and a child's own edit does not change its parent's edit time. And since the total is only known when the walk ends, the status page cannot say "412 of 1,930" mid-run. It says how many pages have been seen so far and that the total is not yet known β
totalstays at the-1sentinel until the run completes. - There was no way to rotate a token. Β§12 said "rotation is replacing the row" and the page offered no form to do it with; the only route was disconnect and reconnect. Found by mutation-testing
TestTokenIsNeverRendered, which was passing because the page it looked at had no token field at all. - Ancestry was indexed and never shown. Search results rendered the in-page heading trail and not the source path, so two pages called "Week one" under different handbooks were indistinguishable to the reader. Carrying ancestry for findability while hiding it from the person reading the answer is half a feature. Found by looking at the rendered page, which is the only thing that would have found it.
- The scheme pin became its own check. Β§11 described a host pin. The implementation also rejected non-loopback plaintext hosts, and that second condition caught the host-pin gate's test target β so deleting the host pin left the gate green. Host and scheme are now separate fields with separate checks and separate gates.
Two smaller ones: tools/drive wrote to a hardcoded absolute path under one person's home directory and panicked anywhere else, now the OS temp dir; and the connector page ordered token rotation above import status, which put a rare housekeeping act between the reader and the thing the page is for.
1.3 What the Fable code review changed
The branch was reviewed by claude-fable-5-1 before merge β REQUEST CHANGES, 12 findings, one blocker. Recorded here, as Β§1.1 and Β§1.2 are.
- Nested pages were ingested as roots with no ancestry (blocker).
Rootskept every search result whose parent was not itself in the result set, on the assumption β taken from this design, and wrong β that search returns only directly-shared pages. Notion's reference says it returns "all parent or child pages ... shared with a connection", and a page nested in a toggle has ablock_idparent, which search never returns. Such a page therefore always looked like a root, was queued first, was ingested with an EMPTY path and marked visited, and the correct Ref was discarded when the traversal reached it. On the next run search might include the ancestor and the page would flap between two ancestries, re-ingesting each time. The fake had asearchLeaksswitch built for exactly this and no test using it. Roots and orphans are now separate, and orphans are walked only after the real traversal drains. - A page was not findable by its own title. A Notion title is a property, not a block, so it reached none of heading, body or path. Fixed by the merge with main, which had already found the same bug for pasted documents and added a title column.
- The heartbeat was counted in pages, not time, so a slow-but-healthy run could be declared stale, "reclaimed", and joined by a second goroutine walking the same ledger. It is a 20-second ticker now.
- The status page said "0 so far" for a whole run, because the counters were written only at completion β a count maintained nowhere, rendered as a confident zero, on the page written to honour that rule. The ticker publishes them.
- Database rows imported with no properties, and
Propertieswas dead code. Β§5 promised the key/value block and warned that without it "a forty-row database becomes forty blank documents"; that is precisely what shipped. - Data sources sat in the ledger as pages that never import, so the page read "1 of 2 imported" forever. Containers are no longer ledgered β and "imported" now counts rows memoria HOLDS A DOCUMENT for, not rows whose last fetch happened to succeed, so a page that 404s after importing no longer drops out of its own total.
- Ledger writes failed silently.
pageRowreturned(nil, nil)on any error, so a database failure was indistinguishable from "never seen this page" and the mark was skipped with nothing logged. And a failure on a page memoria already held wiped itsdocument_id, erasing the record of a document that was still there. - Smaller: the production-seam gate greped only for
AppForTest, soAsPersonin main would have compiled and made every request an owner;Retry-Afterwas uncapped and ignored the context, so one hostile value parked a run for a day; the pagination cursor went into a query string unescaped; database failures reported asnetwork, so the page said "We could not reach Notion" about a local write; and two gate comments claimed more than their gates tested.
1.4 Sequencing, as it actually went
Β§14 says stage 1 lands and is reviewed on its own. It did not: all three stages landed on one branch and were reviewed together. Recorded rather than quietly diverged from β the cost was a larger review than the design intended, and the review found a blocker that a stage-2-only review would have found sooner.
Β§15's ship requirement is still unmet. No run against a real Notion workspace has happened. Finding 1 above is exactly the failure Β§15 predicts from "a connector verified only against fixtures its author also wrote": it was found by a reviewer reading Notion's documentation, not by a test. That requirement stands.
---
2. Why this is unblocked, when the parent sequenced it fourth
The parent's Β§11 puts external connectors in slice 3, behind the sibling contract (slice 1) β which parent Β§13 records as "blocked on agreement, not on code", in other repositories. That ordering was never a dependency. Slice 3's stated requirements are least-privilege credentials (parent Β§3.5) and gate 7, both local. Notion content is memoria-owned under Β§4.2, so:
- No S2 call. There is no sibling that could answer one.
- No person ref, so no dependency on home Β§13 step 4.
- No content egress. Bodies come in. The footer's claim β "no document ever leaves this instance" β stays true and
TestFooterDoesNotOverclaimstays green.
Draft 1 said "Notion sits on slice 0 and nothing else", which the review corrected: it also sits on slice 0's test infrastructure, because TestNoOutboundNetworkInAppCode forbids the client this design needs (Β§11), and on AppForTest growing an identity seam (Β§12). Those are dependencies. They are in this repository, which is the point.
---
3. Deletion: this changes the parent
Custody is the integration's share list. An internal integration sees only what is shared with it, and that act of sharing is the publish (parent Β§5). memoria adds no second selector β parent Β§3.5's "owner-selected subsets" is satisfied by the far end already having a per-page control, and a second list to keep in step with a list is the shape decision 11 rejected.
Authorization is memoria's own admission gate and nothing else (Β§4.2). Every admitted person sees every Notion document memoria holds.
On absence, memoria does nothing.
| Upstream event | memoria does | |---|---| | Page edited, renamed, or moved | re-ingest (Β§10 β all three, which draft 1 got wrong) | | Page added under a shared root | ingest | | Page unshared, trashed, or the token revoked | nothing. The document stands until someone deletes it in memoria |
This is a change to the parent, not an application of it. Parent Β§4.2 says "Β§5.1's enumeration diff is a convenience that eventually notices upstream deletions", and parent Β§5.1's Notion row records Delete: diff only. Draft 1 removed the diff and cited parent Β§3.6's README paragraph as evidence the parent already agreed. It is not evidence: Β§3.6 is about disconnecting a space or revoking access, a different event from a single page archived while the connection stays live. The review was right to call that smuggled.
The argument for the change, made properly. memoria is being adopted as the replacement for Notion, not as a mirror of it. Under a replacement, archiving a page in Notion after migrating it is the intended workflow β and a diff-and-delete connector reads that as "forget it", deleting from the app of record the document the migration existed to preserve. A convenience that eventually notices deletions is the wrong convenience when deletion upstream is the goal.
What it costs, and the mitigation. A page deleted upstream because it should not have been shared stays indexed and readable by every admitted person, with no notice. That is a real cost and Β§12 puts it in the interface. Every ledger row therefore carries last_seen_at, written on every enumeration β it costs nothing, since traversal already touches every row β so the "no longer in Notion" view is buildable later without a migration. Decision 3 keeps the documents; it does not license being unable to list them.
Parent amendments required in this commit: Β§4.2's "eventually notices upstream deletions" and Β§5.1's Notion Delete cell. Not doing so leaves two specs that contradict, which is the drift CLAUDE.md exists to prevent.
---
4. The Notion object model we are coding against
Pinned at Notion-Version: 2026-03-11, a constant in the client with a gate asserting it. This matters more than a pin usually does: version 2025-09-03 changed the object model incompatibly. A database now contains one or more data sources; a row's parent is {type: "data_source_id", data_source_id, database_id}; /v1/databases/{id}/query is replaced by /v1/data_sources/{id}/query. 2026-03-11 further renames archived β in_trash and the transcription block type β meeting_notes. Draft 1 described the pre-2025 model and listed pinning as an open item; it is a decision with consequences for every parent link the ancestry walk reads.
Trashed content is skipped: in_trash: true is not ingested, and an already-ingested page that goes to trash is an absence, which by decision 3 means nothing happens.
---
5. What a Notion page becomes
One page β one Document, Source = "notion".
Body is Markdown extracted from the block tree: paragraph, heading_1..3 β #/##/###, bulleted and numbered list items, to_do β - [ ]/- [x], quote, callout, toggle (heading line then children), code (fenced, language preserved), divider, table (pipe table), image/file/video/pdf/meeting_notes (caption and filename only β memoria indexes text), and child_page/child_database as a one-line reference, never inlined: they are their own documents and inlining would duplicate every passage into all of its ancestors. Rich text uses Notion's plain_text, so an inline equation or mention degrades to the text Notion itself renders.
Unknown block types produce nothing and increment a counter. Not an error β Notion adds block types, and a connector that fails a run on an unrecognised one breaks on Notion's release schedule. But not silence either: the run reports "N blocks of a type memoria does not read", so a page that came in thin has a reason attached rather than looking like a thin page.
Empty pages are ingested as title-only documents. Draft 1 said an empty page "ingests as an empty body"; store.go:73 and :101 refuse exactly that, so the gate asserted something the store forbids. Real workspaces are full of placeholders and title-only rows, and both available alternatives were wrong: failing the run lets one placeholder block the whole import, and skipping makes the page an absence with no row to explain it. So Store.Ingest grows an explicit AllowTitleOnly option β off for uploads and paste, where an empty submission is a user error worth refusing, on for connectors, where it is a fact about the source. The document's body is its title; it chunks to one passage; the ledger row records empty and the run counts it.
Databases. A database is not a document; each row is a page and becomes one, with properties rendered as a leading key/value block:
Status: Shipped
Owner: Ana
Due: 2026-03-01
<the row page's blocks>relation renders the related pages' titles, rollup and formula their computed value. This is the part most likely to be wrong β a 40-row database becomes 40 one-paragraph documents, which may be noise rather than memory. Β§15 records it as unanswerable without a real workspace.
Title is the page's title property. A database row with an empty title gets its data source's name plus its first non-empty property, because firstLine(body) over a property block titles every document Status: Shipped.
External id is the Notion page id β stable across edits, renames and moves, which is what Document.ExternalID's contract requires. It is keyed through seal.Fingerprint like every other external id (parent Β§3.2: a coordinate must not be an oracle).
---
6. Enumeration: traversal, not search
Draft 1 enumerated with /v1/search and promised a total decoder. It cannot be done. Notion's documentation is explicit that search "is not optimized for β¦ exhaustively enumerating through all the documents that a bot has access to", that "search is not guaranteed to return everything", and that its index is eventually consistent. Crucially there is no field in the response that says the result was partial. So a run that pages cleanly to has_more: false, with no 429 and no error, may simply be missing pages β and would report a complete import. No decoder, however total, can detect it.
The same documentation supplies the way out:
> any pages or databases that are directly shared with a connection > are guaranteed to be returned
So search is reliable for exactly one thing, and that is what we use it for:
- Roots.
POST /v1/searchfinds the directly-shared pages and data sources β guaranteed complete for that set, which is the set an admin chose. - Traversal. From each root, walk down:
GET /v1/blocks/{id}/childrenforchild_pageandchild_database, andPOST /v1/data_sources/{id}/queryfor rows. Both are cursor-paginated with a properhas_more/next_cursorcontract, and both are complete β they enumerate a parent's actual children, not an index's opinion of them.
Ancestry falls out for free. You arrive at every page from its parent, so the path is known at the moment of fetch. Draft 1 walked parent links upward, which needed raw ids of pages possibly outside the run, had to hop block_id parents for inline child pages, and truncated silently when an ancestor was unshared. None of that exists now.
The decoder is total β the parent's Β§4.1 rule, applied where it can actually hold, because these endpoints support it:
has_more: truewith nonext_cursorβ error.- A repeated cursor β error. (A looping API would otherwise produce a run that never finishes and never says why.)
- A short page with
has_more: trueβ accepted; the cursor is the completeness signal, not the count. 429/529past the retry budget β error. Both carryRetry-Afterin integer seconds and both are honoured exactly;500/502/503/504are retried only because every request here is a read.- An id seen twice β deduplicated and counted; silent dedup hides a traversal bug. A cycle (a page reachable from itself) is bounded by the visited set and counted the same way.
There is no partial return anywhere in the package's surface: a caller cannot mistake "we got some" for "that is all there is", because "some" is not a value the package produces.
What this still does not guarantee, said plainly: memoria enumerates everything reachable from the roots the integration was given. A page shared with nobody, or in a space the integration cannot see, is not missing β it was never in scope. The status page says how many roots were found and names them, so "I expected more" is answerable.
---
7. Ancestry, and the scoring trap in it
A Notion page's meaning is largely positional: "Week 1" under "Handbook βΊ Onboarding" is onboarding week 1; the same page floating free is nothing. memoria's chunk trail today comes only from ATX headings inside one document, so a naive import loses all of it.
Each document therefore carries a path: the ancestor titles from the root down, known from traversal (Β§6).
The trap. The index has two FTS5 columns scored bm25(chunks, 3.0, 1.0) β heading 3Γ, body 1Γ (index.go:165). Prepending the path onto the heading trail puts the workspace's top-level names into every chunk of every page beneath them at triple weight. A search for handbook returns the whole handbook above the page that answers the question, and it gets worse with depth. The symptom is bad ranking, not an error, which is the kind that survives.
Therefore: a third FTS column, path, scored bm25(chunks, 3.0, 1.0, 0.5). chunk.Split is unchanged; a sibling chunk.SplitUnder(path, text) carries the path without merging it into the document's own trail. Store.Ingest moves to an options struct β one production call site (handlers.go:183) and one dev tool (tools/seed/main.go:115).
Ancestry is also SHOWN, above the title on every search result, not merely indexed. A result carrying only its in-page heading trail leaves two "Week one" pages under different handbooks indistinguishable to the reader (Β§1.2).
The 0.5 is a guess, chosen as half the body's weight because nothing has measured it, and the code says so where the constant lives. Β§15 keeps it open. What is not a guess is that the gate under it must actually fail when the weighting is wrong β see Β§13 N3, which is where draft 1's version fell down.
---
8. Data
Additive-only migrations, per AGENTS.md.
Connector β kind ("notion"), sealed_token (PurposeCredential), workspace_print, last_verified_at, timestamps.
SyncRun β connector_id, state (enumerating|fetching|done|failed|interrupted), roots, total, fetched, skipped, empty, failed_pages, unread_blocks, error_code, heartbeat_at, timestamps. total is -1 until enumeration completes, following handlers.go:62's sentinel β a total that could not be determined is not zero pages.
error_code is a code, never a message, and Notion supplies the concrete reason: a 404 body reads "Could not find database with ID: be907abe-β¦ Make sure the relevant pages and databases are shared with your connection \"My Connection\"" β a page id and a connection name, one %w away from a log line. The closed set is unauthorized, rate_limited, traversal_failed, extract_failed, network, interrupted; the UI maps it to copy as flashFor already does.
NotionPage β the ledger, keyed per connector, not per run, which is what makes a second run resume rather than restart:
| Column | Why | |---|---| | page_print | keyed page id β lookup, and joins to Document.ExternalID | | sealed_page_id | the id itself, sealed under PurposeCoordinate. Reversible, because the fetch phase needs the id back (Β§1.1 finding 1) | | sealed_path | the ancestor trail at enumeration time | | path_print | keyed path β lets a move or an ancestor rename be detected without opening the row | | seen_edited_time | what the last enumeration saw | | ingested_edited_time | what memoria actually ingested. Two columns, because one compares against itself (Β§1.1) | | state | pending\|fetched\|empty\|failed | | error_code | per row, so one bad page is not a whole bad run | | last_seen_at | Β§3's mitigation | | document_id | the row it produced |
Document.SealedPath under a new seal.PurposePath. A path is content: "Acquisitions βΊ Project Bluebird" names a thing whether or not anyone opens the page. Sealing it under the title's purpose would be cheaper and is refused β one HKDF purpose per kind of thing sealed.
New purposes: PurposePath, PurposeCredential, PurposeCoordinate.
No request body is bound onto a GORM model; the connector form goes through an explicit map[string]any and a .Select allowlist. Neither scope.Owned nor scope.OwnedBy appears β these rows are team-shared, which is AGENTS.md's first mechanical rule.
---
9. The run loop, and the sleep it cannot hide
An owner posts to /connectors/notion/sync, mounted inside the csrf.Protect + requireSession group (app.go:51,60) β a route on the outer mux bypasses both, as /static/ does. The handler starts the run and redirects to a status page that refreshes with <meta http-equiv="refresh">. Scriptless, per AGENTS.md.
One pass, checkpointed per page. Draft 2 specified two phases, enumerate then fetch. With traversal they are the same API call β fetching a page returns its body and the child pages beneath it β so separating them would double every request against a limit of three per second (Β§1.2).
The loop walks from the roots (Β§6); for each page it writes the ledger row first (coordinate, ancestry, seen_edited, last_seen), then fetches, then ingests if Β§10 says the page needs work, then records the outcome (state, ingested_edited, ingested_path).
Writing the row BEFORE the fetch is deliberate: a page that then fails to read has somewhere to be named, instead of being absent.
What an interruption actually costs. The ingest work already done survives β a resumed run skips every page that is unchanged. The traversal is repeated, because children cannot be discovered without asking for them, and a child's own edit does not move its parent's edit time. So a resume is cheap in the expensive currency (chunking, sealing, indexing) and not free in API calls.
Single-flight, and the deadlock draft 1 shipped. A run holds the connector via a conditional update. Draft 1 called the lock's survival across a restart a feature; it is the bug. The goroutine dies when CARLOS sleeps the process, having written nothing, and the row stays fetching β so every later sync is refused as "already running", permanently, on the normal path. Two mechanisms fix it:
- At boot, any run in
enumeratingorfetchingbecomesinterrupted. There can be no live run at boot, so this is sound by construction rather than by timeout. - A
heartbeat_atwritten every N pages, so a run wedged inside a live process is reclaimable after a stated interval rather than never.
What the sleep costs, said out loud. CARLOS sleeps an idle app; a sleeping app syncs nothing. At ~3 requests/second a large workspace is tens of minutes, so runs will be interrupted. The status page says the sync progresses while the page is open and resumes where it stopped.
And the honest naming, which draft 1 fudged: this is a request-spawned goroutine that runs until the platform sleeps the process, kept alive by an open status tab β not a background worker, and context.WithoutCancel here is not the same thing as store.go:189's bounded post-commit index refresh. It also sits against parent Β§3.5's "memoria hibernates": an open status tab deliberately prevents that, for as long as it is open. Β§15 records the scheduler that would replace it.
---
10. Re-ingest: edits, renames, and moves
Draft 1 handled only edits, and silently. Three conditions mark a ledger row as needing work:
seen_edited_time != ingested_edited_timeβ the page changed.path_printchanged β the page moved, or an ancestor was renamed. An ancestor rename does not touch a descendant'slast_edited_timeat all, so without this the descendants keep a stale path forever.stateispendingorfailed.
And in the store: Ingest's unchanged-body short-circuit (store.go:113) currently returns without touching sealed_title, so a renamed page keeps its old title indefinitely. It must update title and path when only those changed, without bumping the revision β a revision that moves without the content moving would make every cached authorization stale for no reason, which is the comment already at that line, and it stays true.
---
11. Egress, and the gate that forbids it
internal/memoriatest/gates_test.go:92's TestNoOutboundNetworkInAppCode walks internal/memoria/** and cmd/** and fails on *http.Client, http.NewRequest, net.Dial and more. It is slice 0's enforcement of the parent's AI-off floor. A Notion client trips it on its first line, and draft 1 did not mention it β leaving the implementer's cheapest move to be widening the gate, with no rule for how far.
The rule: the gate is narrowed to something stronger, never deleted.
- The source grep keeps its blanket ban over
internal/memoria/**andcmd/**, with exactly one exemption:internal/memoria/notion. - That exemption is paid for by a runtime guard, which is stronger than the grep it replaces there. The package's client is built on a transport that refuses any host but
api.notion.comand refuses to follow redirects at all. Both matter: the token travels in anAuthorizationheader, and while Go drops that header across hosts, a same-host redirect keeps it, and a base URL built from stored input would send it anywhere. The base URL is a constant; nothing configurable feeds it. - Gate N8 asserts both, and gate N9 asserts no other package acquired a client.
Leak list additions to parent Β§3.2:
- memoria now holds a Notion token, sealed under its own purpose. The parent's live-host entry already names "Β§5.1's connector credentials"; this makes it real rather than prospective.
- memoria now dials
api.notion.com, so Notion learns the instance's egress address and sync cadence. No content of the team's goes out β the traffic is Notion's own content coming in β but the parent lists the sign-in dial for carrying no content either, and the same standard applies. - The ledger leaks a page count and an edit-time distribution from the bucket.
The AI-off floor is unchanged: with the assistant off, no document of the team's leaves the instance, at ingest or at query.
---
12. Credentials, privilege, and surfaces
The token is a Notion internal integration token, pasted by an owner, sealed under PurposeCredential, never rendered back β not even masked, because a masked secret is still a secret in the DOM. Verified on save with one /v1/users/me call, so a bad token fails at the form rather than mid-run. Rotation is replacing the row through a form on the connector page; there is no key history, because a replaced token has no use. Disconnect removes the row β parent Β§3.6 promises "disconnecting" and draft 1 gave no affordance for it. Disconnecting deletes the credential and stops future syncs; by decision 3 it deletes no documents, and the confirmation says so in those words.
Least privilege (parent Β§3.5, "a requirement, not a nicety"): a read-only internal integration with no user information, shared with the specific pages to import rather than the workspace root. The setup copy says this because the connector cannot enforce it β Notion decides what a token can do, and memoria can only ask for the right one and refuse to pretend otherwise.
"Owner" is Config.Owners, and in slice 0 that is the whole admitted set. Draft 1 wrote "owners only" as though it were a restriction; it is not one today, and stating it as a gate while it gates nothing is the pattern AGENTS.md opens with. So: the routes are guarded by an explicit requireOwner that reads the session identity and checks Config.Owners; it is currently equivalent to requireSession and the code says so at the definition. To make it testable at all, AppForTest grows an injectable identity β today it bypasses RequireSession entirely, so no identity reaches the context and no such gate could be written. Β§15 records that slice 1, which replaces the owners list with S7, must supply an owner predicate or these routes lose their guard.
Surfaces. /connectors lists connectors; /connectors/notion is setup, status and history. Scriptless, attribute grammar, colour tokens, theme.css linked. Three things the interface must say, each a place this design could mislead:
- "memoria keeps what it imports." On the setup page, before the token is entered β parent Β§3.6 has the wording.
- Interrupted is not progress, and a running total is not a total. A run stopped by a sleep says so and offers resume. A run still going reports how many pages it has SEEN so far and states that the total is not known until it finishes β because with one pass (Β§1.2) the denominator does not exist until the walk ends.
totalholds-1until then and is never rendered as 0. - A count that could not be determined says so β
totalis-1until enumeration finishes, and renders as "counting", never 0.
Plus the run's honest totals: roots found, pages fetched, skipped, empty, failed, and blocks not understood. Copy goes through the copy-review gate before it is written to a file.
---
13. Gates
Every gate is written, then broken so it goes red, then reverted β per path, with the mutation and date recorded in the test comment.
N1 β TestTraversalIncompleteFailsTheRun. A fake whose blocks/{id}/children returns has_more: true with no next_cursor, and one that 429s past budget. Run ends failed with traversal_failed; the status page does not say complete. Mutation: return accumulated results with a nil error on retry exhaustion.
N2 β TestSearchIsNotUsedAsAnEnumerator. The traversal must not depend on search returning descendants: a fake whose /v1/search returns only the roots while children exist below must still import every child. Mutation: enumerate from search results instead of traversing. This is the gate that pins Β§6's whole reason for existing, and draft 1 had nothing like it.
N3 β TestAncestryCannotOutrankContent. Draft 1's version could not fail: BM25 sums per-column contributions, so a row matching path and body beats a path-only match under any positive weights. The fixture that reproduces the real trap: 20 pages under a root titled "Handbook", none of whose bodies contain "handbook", and one page under "Engineering" whose body contains it once. Query "handbook"; the Engineering page must rank first. Mutation: move the path into the heading column at 3.0. The flip must be measured and both orderings recorded in the test comment β if the mutation does not actually flip the order, the fixture is wrong and must be strengthened until it does. A gate for a ranking property is worthless unless the ranking has been observed to change.
N4 β TestTitleOnlyPageIsIngested. A page with no blocks becomes a title-only document, the ledger row is empty, and the run counts it. Mutation: drop AllowTitleOnly, so Ingest refuses and the run fails.
N5 β TestUnreadBlocksAreCountedNotSwallowed. Mutation: drop the counter increment.
N6 β TestAFailedPageIsNamedNotAbsent. One page of 5 404s. The run completes, failed_pages is 1, the row is failed with a code, and the status page does not read as a complete import. Mutation: continue on fetch error.
N7 β TestSyncNeverDeletesOnAbsence. Two pages ingested, then a traversal returning one. Both documents survive and the run is done β absence is not an error either. Mutation: add the diff-and-delete draft 1's predecessor proposed.
N8 β TestNotionClientDialsOnlyNotionAndRefusesRedirects. The transport rejects any other host, and refuses a 302 even to the same host. Mutations: change the base URL constant; enable redirects.
N9 β TestOnlyTheNotionPackageReachesTheNetwork. The narrowed source grep, with the exemption asserted to be exactly one directory. Mutation: add an http.Client to internal/memoria/handlers.go.
N10 β TestRenameAndMoveReachMemoria. Rename a page; sync; the new title renders. Rename an ancestor; sync; the descendant's path updates though its own last_edited_time never changed. Mutation: compare only seen_edited_time.
N11 β TestResumeContinuesRatherThanRestarts and N12 β TestInterruptedRunDoesNotWedgeTheConnector. Mark a run fetching, rebuild the app (a boot), POST sync: a new run starts and the old is interrupted. Mutations: scope the ledger by run id; remove the boot-time reclaim.
N13 β TestNoNotionContentInLogs extends gate 4 to the new packages: no title, body, path, page id or token in any log line, error string or metric label, observed over a run driven end to end against the fake β whose error bodies carry a canary title and a canary page id, so the error path is actually exercised. Parent Β§13 notes the existing gate 4's credential half cannot fail; this one is built not to repeat that. Mutation: log the page title at Info in the fetch loop.
N14 β TestTokenIsNeverRendered. With a token stored, the connector page's HTML contains neither the whole token nor any 8-character run of it. (Draft 1 said "no substring", which every single character satisfies.) Mutation: render it into the input's value.
N15 β TestNotionVersionIsPinned. The client sends Notion-Version: 2026-03-11. Mutation: drop the header.
And the run is looked at in a browser via tools/drive, per AGENTS.md β which found three bugs in its first use that the whole Go suite passed over. A template test proves markup exists, not that a sync's progress reads sensibly at 40%.
---
14. Sequencing: three stages, not one
The review's last finding, and it is right β draft 1 was three pieces of work wearing one spec.
Stage 1 β ranking. The third FTS column, SplitUnder, the Ingest options struct, SealedPath, AllowTitleOnly. No Notion code at all. This re-ranks every existing document, so it lands and is reviewed on its own, with N3's flip measured before anything depends on it.
Stage 2 β the client. internal/memoria/notion: transport, host pin, total decoder, traversal, Markdown extraction, the fake server. Pure, no DB, no app types. Gates N1, N2, N8, N9, N15.
Stage 3 β the connector. Credential, ledger, run loop, boot reclaim, surfaces, copy. Gates N4βN7, N10βN14.
---
15. Open
- memoria cannot replace Notion yet, and this connector does not change that. It has no editing surface. This moves the corpus, which has to happen first under any plan, but until someone can write in memoria, Notion remains where new pages are made. Recorded so the migration is not mistaken for the replacement.
- No real workspace has been touched. Every test runs against a fake built from the documentation. AGENTS.md's first broken rule is about guards that cannot fail for the thing they name, and a connector verified only against fixtures its author also wrote is that risk in a different costume β the review agreed and asked for it to be promoted. A run against the real workspace is a ship requirement, owned by Paul, blocking stage 3's merge and nothing earlier.
- The 0.5 path weight is unmeasured (Β§7), and N3 measures only that the ordering flips, not that 0.5 is the right number. Tune against a real workspace and a real query set.
- Database rows as documents may be noise (Β§5). One look at a real import settles it.
- The scheduler. Β§9's open status tab is a workaround for having no background worker, and it defeats hibernation while open. Whether CARLOS offers a scheduler is parent Β§13's open question about scratch storage in a second costume; if it does, decision 8 changes.
- Slice 1 must supply an owner predicate (Β§12), or
/connectorsloses its guard when the owners list is replaced by S7. - Listing what is no longer in Notion. Decision 3 keeps such documents and Β§3 stores
last_seen_atso the view is buildable; the view itself is not built. - Review pass on draft 2. Draft 1:
claude-fable-5-1, REQUEST CHANGES, 15 findings, Β§1.1. Draft 2 is unreviewed.