Skip to content

fix: make adding, indexing, and renaming sources work - #100

Merged
siracusa5 merged 33 commits into
mainfrom
c/context-cake-source-audit-a3b7ea
Aug 7, 2026
Merged

fix: make adding, indexing, and renaming sources work#100
siracusa5 merged 33 commits into
mainfrom
c/context-cake-source-audit-a3b7ea

Conversation

@siracusa5

@siracusa5 siracusa5 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

A field test of the shipped 0.5.0 Mac app failed at the first mile: adding an Obsidian vault appeared to work and then hung without ever being added, renaming a git-backed source hung on "Saving…", and the app felt laggy throughout. Four parallel audits plus empirical reproduction found these were four independent defects stacked on each other, not one bug.

This PR fixes all of them and adds the regression tests whose absence let them ship. The headline root cause of "it showed and then just hung there": the console declared headingText(heading: string) and called .replace on it, but the engine legitimately returns heading: null for a note with no # line — the normal shape of an Obsidian note, since Obsidian titles by filename. That TypeError killed the render, and the store's silent give-up (three retries, then permanent silence) hid it.

Changes

Indexing survives a vault that is being used

  • The layer watcher filtered nothing, so every write to .obsidian/workspace.json — which Obsidian rewrites continuously while open — cancelled and restarted the index. Events are now filtered by the walker's own skip rules, and a dirtied pass coalesces into one follow-up behind an adaptive quiet period instead of chaining restarts forever.
  • Reproduced before the fix: 3,000 notes index in ~2.8s untouched, but under churn the progress sawtoothed (1375 → 150 → 1950 → …) and never completed in 45s.
  • A source that already has a good snapshot now stays ready with an additive indexing.refreshing flag while it re-reads, rather than flipping back to "indexing".

Rename stopped re-reading the corpus

  • The index key was JSON.stringify(layer), which includes name and level, so a rename minted a new key and re-read every file with no snapshot. Keys are now built from content identity (index-keys.mjs), and an orphaned entry is moved to its new key when its validity matches. Rename is now ~8ms with zero documents re-read.

/api/graph no longer costs seconds

  • It re-resolved and re-tokenized the entire corpus on every request, uncached, while the console polled it every 900ms. Measured at 14.5s per call on a real 139MB vault. It now answers from index snapshots with a per-generation memo.
  • Added GET /api/status — O(sources), no resolve, no tokenize. Previously /api/graph was the only route carrying index progress, which is precisely why the console polled the most expensive endpoint in the system.

One bad layer no longer bricks the app

  • A single malformed layer threw inside readContextManifestopenSources(), and every route calls openSources(), so the whole API returned 500. Invalid layers are now quarantined on the read path as inert error rows. Writes still validate strictly; nothing invalid is ever constructed or persisted.

The UI tells the truth

  • Background refresh failures are visible and retried indefinitely instead of going silent after three attempts.
  • Per-source progress is real ("Reading — 1,240 / 3,000"); an indexing source can no longer render as "synced · 0 concepts".
  • A shell-wide background-activity indicator is visible from every destination, with a popover listing each running task.
  • The add wizard verifies the source actually landed instead of declaring success on the POST alone.
  • Heavy payloads are refetched only when a cheap generation counter moves; polling pauses on a hidden document.

Hardening

  • Local adapters cap per-file reads at 2MB (the GitHub adapter already did); permission-blocked subtrees are surfaced as warnings instead of indexing silently partial; the document walk is abortable; sourceBudgetMs default raised 30s → 120s for large vaults on cloud storage.

Test Plan

  • Local tests pass — full npm test exits 0, zero failures
  • Retrieval eval unchanged (recall@1 0.895, recall@5 1.000, mrr 0.947) — no ranking tuned
  • Two new regression suites written red first, against the reproduced bugs: index-stability-test.sh (churn, rename, quarantine) and graph-latency-test.sh (responsiveness SLO + graph budget). Both include guards that fail loudly if the fixture is too small to gate the bug — at the size originally planned, the churn assertion passed with the bug present.
  • index-lifecycle-test.sh covers per-layer keys, snapshot handoff, policy re-keys, dotted directories, bounded refresh
  • Console suite 230 tests; desktop npm run smoke OK
  • Verified end-to-end against a real 139MB Obsidian-shaped vault (3,000 notes, avg 47.6KB, live .obsidian/ churn), driving the actual console UI against the actual engine
  • CI checks pass

Measured before → after:

before after
Vault add under live Obsidian churn never completes completes, monotonic progress
Rename a settled 3,000-concept source full re-read, drops to 0 ~8ms, holds 3,000, no re-index
/api/graph on a 139MB vault 14,554ms 64ms cold, ~2ms warm
Request latency while indexing p95 2.9ms

Notes

  • Two audit premises were measured and disproved, and the plan was corrected rather than followed. Indexing does not starve the event loop — snapshotSource awaits real async I/O per document, so the loop is released every document, not every 25. A planned worker-thread rewrite was demoted rather than built, because it would have hidden a cost that should be deleted. YIELD_EVERY is untouched.
  • Two adversarial reviews found defects the passing gate missed, all fixed here with revert-proofs. The most serious: a layer field literally named kind could shadow identity.kind and make two different sources share one index snapshot, serving one source's documents under another's name and precedence level.
  • Known gap, deliberately not in this PR: the source navigator. Sources still has no link into the file browser, no per-source scoping, and its detail panel still says "remove the source and add it again" to change a path. Planned as the immediate fast-follow.
  • A quarantined layer can now be removed and repaired through the API — that fix landed on this branch during review (the write path still validates strictly; the repair reads tolerantly and persists only a manifest that validates).
  • Reviewed by five independent passes before merge: two adversarial reviews of the index lifecycle, one adversarial review of the graph/console work, a code review, a security review, and a test-suite audit that verified each gate by reverting the fix it guards. Every MUST FIX was addressed on the branch. Two are worth calling out because they were the same class of bug this PR exists to remove: /api/graph pinned its payload at one instant but computed generation after the await, so a client could store a counter describing data it never received and then never refetch; and the console committed its poll gate before the heavy refetch, so a failed refetch reported success and froze the page. Both are fixed and gated by tests.
  • The test-suite audit also found that the .obsidian watcher filter was ungated — deleting it left both new suites green, because the coalescing fix alone satisfied them. There is now an assertion that fails if the filter is removed.
  • packages/core remains dependency-free. Resolver behavior and the MCP tools baseline are unchanged.

@siracusa5 siracusa5 added the bug Something isn't working label Aug 7, 2026
siracusa5 and others added 13 commits August 7, 2026 09:18
…, bad-layer quarantine)

Three failures a user hit on the shipped 0.5.0 Mac app, written as the gate
before the fix. The suite is RED on purpose — Phases 1-3 make it green without
touching the assertions.

1. An open Obsidian vault never finishes indexing. The layer-root fs.watch is
   unfiltered, and every event debounces into invalidateIndex, which cancels
   and restarts the running job; .obsidian/workspace.json is rewritten the
   whole time the vault is open. A control host indexes the identical corpus
   untouched first, so a failure here is the watcher and not a slow machine —
   and the observation window is derived from that measurement rather than
   fixed, so a loaded runner buys time instead of a false failure.

2. Renaming a source re-reads it from scratch. The index cache key is
   JSON.stringify(layer), so a rename mints a new key and the source blinks to
   zero concepts. The observer watches the whole window, not the end of it: a
   re-index that finishes inside five seconds still blanked the graph on the
   way through.

3. One malformed layer answers 500 on every route, /api/settings included —
   the screen a user would need to fix the bad source.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Review found the fixture size was doing load-bearing work silently: at
INDEX_STABILITY_NOTES=800 the control settles in ~740ms and assertion 1 goes
GREEN against an unfixed engine, because a corpus that fast finishes inside the
gap between watcher restarts. Two preflight guards close both ways that can
happen, and both fail loudly rather than skipping — a gate that quietly stops
gating is worse than no gate.

- Control floor: a control pass must clear 4x the watcher's 250ms debounce.
  Below that the suite reports the corpus as too small, names the measured and
  required times, and suggests a scaled INDEX_STABILITY_NOTES. The existing
  READY_TIMEOUT_MS derivation protects a slow machine from a false failure;
  this protects a fast one from a false pass.

- Recursive-watch probe: service.mjs silently falls back to a non-recursive
  fs.watch when the recursive call throws (inotify exhaustion on a busy runner
  is the realistic way in), and in that state no write under .obsidian/ ever
  reaches the watcher. The probe writes into a subdirectory of a watched temp
  dir and requires an event. Verified to fire on Linux with the fallback
  simulated; macOS delivers subdirectory events either way, so the probe passes
  there on merit.

Also moves the suite to the end of the test chain. It is red for the duration
of the fix work, and && meant npm run eval, test:metrics and
test:release-workflow were unreachable behind it — the retrieval eval in
particular has to stay runnable while the engine changes.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…get test

Two numbers behind the field report that the shipped 0.5.0 Mac app feels
laggy, written as the gate before the fix. The fixture is the realistic one:
3,000 documents at 10-50KB (89MB, avg 30.5KB), because small documents hide
the per-document CPU cost both assertions exist to measure.

GRAPH LATENCY — RED, and the whole of the reported lag. buildGraph resolves
every concept and runs an exact BPE countTokens on every request, uncached.
Three consecutive calls come in at 4945ms / 5045ms / 5028ms against a 150ms
budget. The flat profile is the tell: there is nothing to warm up. The console
polls this route every 900ms while indexing.

RESPONSIVENESS UNDER INDEXING — GREEN, and kept as a live SLO gate. The plan
expected this to be red on the theory that YIELD_EVERY=25 lets index work
starve the serving loop. It does not: snapshotSource awaits
source.loadConcept(id), and both files.mjs and okf-local.mjs reach fsp for it,
so the loop is released on EVERY document. The 25-document yield only bites
where loadConcept is a Map lookup — snapshotView inside buildGraph. Measured
over a 5.7s index: p50 1.67ms, p95 2.90ms, max 3.60ms against 50ms/250ms.
Keeping the assertion is still worth it — it goes red the day someone raises
YIELD_EVERY, drops a yield, or makes loadConcept synchronous.

Three guards, because a fixture that is too small makes both assertions go
green against an unfixed engine — verified, not theorised: at 200 documents of
2KB the graph budget passes at 26ms with the bug fully present. Corpus weight
(bytes and average document size), window length and sample count, and a
concept-count check that the graph being timed is the whole corpus. Each fails
loudly with a scaled re-run knob, and any assertion that passes while a guard
is down is labelled UNGATED so a green line cannot be quoted as evidence.

An idle control probe runs first and reports ~1.4ms, so a high number during
indexing would be starvation rather than measurement overhead. The window is
bounded by polling /api/graph at 500ms — cheap only because buildGraph skips
sources that have not finished indexing, so the readiness signal costs under a
tenth of a percent of the window. The probes issued from the moment that poll
turns expensive are excluded and reported: the server keeps building the graph
that poll triggered, and those two samples were the source of every stray
~100ms max in this window. Excluding them takes the measured max from ~113ms
to ~3.6ms, which is what makes the assertion safe on a slower CI box.

Also prints, as diagnostics rather than assertions, the same probe under the
console's real traffic. That margin is ~1.4x today and would flake in CI;
whoever memoizes buildGraph should revisit promoting it.

Appended after index-stability, keeping the property that Phase 0's gates sit
at the end of the &&-joined chain.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…RINUSE

A run killed with SIGKILL (a harness timeout, an interrupted CI job) never
fires its EXIT trap, so its hosts outlive it and hold 8841-8843 or 8861. The
next run then dies deep inside startup with a bare EADDRINUSE that names
neither the cause nor the fix; two such orphans were found holding
index-stability's ports during Phase 0.

Trap INT and TERM as well as EXIT so the ordinary interrupt paths do clean up,
and preflight the ports so the unrecoverable case (SIGKILL) reports the port,
the reason, and the exact command to find the process.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
walkDocs and probeDocs are one long await from a caller's point of view, so
cancelling the work around them cancelled nothing: the walk ran to the end of
the tree regardless. A layer whose index was cancelled and restarted therefore
accumulated one abandoned walk per restart, all of them competing for the same
disk and the same event loop as the job that replaced them.

Both now take an optional AbortSignal and check it once per directory — a
readdir is the unit of work, and per-entry checking would cost more than it
saves. The signal's own reason is rethrown, so whoever awaits learns whether
the walk was superseded or timed out rather than getting a generic error.

The adapters that own a disk walk (okf-local, files) accept the signal through
listConceptIds and pass it down; withGitSync forwards whatever it is handed so
a live layer's walk is abortable too. Every argument is optional, so an adapter
that ignores it — github, mcp, the memoizing cache wrapper, and any caller
outside the service — behaves exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Two field reports against the shipped 0.5.0 Mac app, one root cause each, both
in the same code path. packages/core/tests/index-stability-test.sh was written
first and is the gate; assertions 1 and 2 go green here, assertion 3 (bad-layer
quarantine) is a separate change and stays red.

ADDING AN OBSIDIAN VAULT HUNG FOREVER. Obsidian rewrites
.obsidian/workspace.json every few hundred milliseconds for as long as a vault
is open. Every watcher event debounced 250ms into invalidateIndex, which
CANCELLED the running index and started a new one — and 300ms is far shorter
than any real index takes, so a 3,000-note vault sawtoothed (1375 loaded, 150,
1950, ...) and never reached ready. Measured: the same vault indexes in 2.8s
untouched.

Two fixes, because either alone is a half-answer. The watcher now filters
exactly what walkDocs filters — dot-entries and node_modules, plus a
known-uninteresting extension where the basename gives one — so the file that
caused this never reaches invalidateIndex at all. And an invalidation that
lands mid-index no longer cancels: it marks the entry dirty and the job starts
exactly one follow-up pass when it lands, success or failure. Filtering keeps
this particular churn out; coalescing is what makes a restart storm impossible
rather than merely unlikely, for the genuine edits a filter must let through.
A document rewritten faster than one pass completes now keeps its full
previous snapshot readable throughout instead of showing zero concepts.

A null filename (macOS reports one) still invalidates, and so does an
extensionless entry: a folder rename delivers the folder, never the documents
inside it. A missed edit is a wrong answer; a needless re-index is only work.

RENAMING A SOURCE HUNG ON "Saving...". The index key was JSON.stringify(layer),
which includes name and level, so a rename minted a new key: pruneIndexes
dropped the finished entry and ensureIndexes started over with no previous
snapshot, blanking the source to 0 concepts and re-reading every file for a
change that touched no content. The key is now the layer's content identity —
every field except name and level, serialized key-order-independently so a
manifest rewrite that only reorders fields costs nothing. Both excluded fields
are safe to exclude because the snapshot holds only {ids, concepts, tokens} and
snapshotView reads name and level off the live source object at resolve time,
so a rename or a precedence change lands immediately at zero cost. A denylist
rather than a list of known identity fields, deliberately: a source option
added later must invalidate the index until someone proves otherwise.

The 0-concept window is closed for the key changes that remain legitimate, too
— an indexing limit edited in Settings, a credential arriving. Those orphan an
entry whose content identity is untouched, so pruneIndexes hands its snapshot
to the replacement, which serves it through the re-read it does still owe.

Also here: the index job's abort signal now reaches listConceptIds, so a
cancelled index stops walking instead of finishing in the background; the
add-form's 5s folder probe aborts its walk at the deadline rather than
answering the form and scanning on; the watcher map is keyed by root with the
layer name resolved when an event fires, fixing a stale closure that made a
renamed layer's watcher invalidate a name matching nothing; and the silent
fallback to non-recursive watching says so on stderr, because in that state an
edit inside a subfolder is never noticed at all. The comment claiming recursive
fs.watch is unsupported on Linux was stale — it has worked since Node 20.13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… silently partial

Three failures a user hit on the shipped 0.5.0 Mac app, none of which the
engine had any way to survive.

One bad layer bricked every endpoint. Manifest validation throws inside
openSources(), which every route calls, so a single hand-edited layer made
/api/settings and /api/graph answer 500 — including the screens needed to
find and fix it. The read path now quarantines a layer that fails validation
instead: it is removed from the manifest the engine reads, so it can never be
built, spawned, watched, or reached by the file APIs, and survives only as an
inert error source that gives it a broken row in the graph. Strictly
subtractive. Rules about how layers RELATE (duplicate name, second live
layer) stay fatal — quarantining one of a well-formed pair would mean
silently picking a winner, which would be more permissive than today. Writes
still validate the whole manifest strictly, so nothing invalid is persisted.

A 500MB .md was read whole into a JS string. Local adapters had no per-file
ceiling at all, where the github adapter has capped since it was written.
walkDocs and both local loadConcept paths now stat before reading and skip
past 2,000,000 bytes — the same number the file editor already refused to
open, now one constant.

A permission-blocked subfolder indexed silently partial: the walk skipped it
and the source reported "ok" with quietly missing documents. Skipping is
still right, but it is now recorded. Both cases ride the snapshot to a
per-source `warnings` count and human-readable `warningMessages`, rendered in
the console's source detail.

Also raises the default per-source time budget from 30s to 120s. The old
value was set against test corpora; a large vault on iCloud or Dropbox spends
its first index waiting on the file provider and came back as an error row
that read like an app bug. Indexing is background work nothing blocks on.

index-stability-test.sh assertion 3 is the gate for the first of these and is
unmodified. The other two are covered by new failing-first assertions in
files-source-test.sh (adapter level) and setup-robustness-test.sh (the row a
user actually sees).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…atus

buildGraph resolved every concept and ran an exact BPE countTokens over the
whole merged corpus on every request, uncached. Measured: 4.8s per call on an
89MB fixture, 14.6s on a real 139MB Obsidian-shaped vault — flat across
consecutive calls, because there was nothing to warm. The console polls this
route every 900ms while indexing, and it polls it because /api/graph was the
ONLY endpoint carrying index progress. That pairing is the whole of the
field-reported lag, so both halves are fixed here.

Making the rebuild cheap, not just memoized — a memo alone would still stall
for 14s on every miss, and a miss happens exactly when an index lands, i.e.
when the user is watching:

- snapshotSource keeps the per-concept token count it already computes, taken
  over the merged shape (mergeConcepts of the single contributor). For any
  concept only one source contributes, resolveConcept reduces to exactly that
  call, so the number is reusable verbatim rather than an approximation.
  Genuinely merged concepts fall back to a cache keyed by their contributors'
  precedence and snapshot generation, so a re-index of one layer only
  re-encodes what that layer touches.
- The concept rows and resolvedTokens are memoized on the (name, level,
  snapshot generation) triple of the sources they read. The key is recomputed
  from live state on every request rather than cleared by an invalidation
  event: snapshots are immutable and generations unique, so there is no trigger
  to forget. Progress, health and warnings are still rebuilt per request, so a
  loaded counter ticking through an index does not throw the expensive half
  away.

GET /api/status is O(sources) by construction — index progress and per-source
state, no resolve, no tokenize — and carries a `generation` counter that moves
whenever the graph payload would differ, so a client can poll it and refetch
the heavy routes only on a real move. /api/graph reports the same counter.

After: 22ms cold / 2ms warm on the 89MB fixture, 64ms cold / 2-3ms warm and
sub-millisecond /api/status on the 139MB vault. resolvedTokens is
byte-identical to before on both. A source's own `tokens` total now counts what
it contributes rather than its raw file text (tombstones, blank-line padding,
sections sharing an anchor) — the same derivation resolvedTokens uses, which is
the point of showing them side by side.

The index path is untouched: it awaits real I/O per document and does not
starve the serving loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…ache

withCache took no arguments on listConceptIds, so a layer with a `cache`
block lost both things that ride on that call. The wrapper is applied to
any kind — a local vault included — so the walk became unabortable (a
cancelled index kept reading to the end, and a churning layer stacked one
live walk per cancelled job) and `notes` never reached walkDocs, which
meant an oversized document or a permission-blocked subtree produced a
source that was missing documents and reported zero warnings. That is the
silently-partial failure the warnings exist to close.

Arguments now go through, and the notes are cached with the ids so the
warnings survive a cache hit rather than flickering off on the second read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…ename

path.extname called a directory a file whenever its name contained a dot,
and directories routinely do: "Archive 2024.10", "notes.old",
"Project.v2". Moving a folder of notes into a watched vault delivers ONE
event, naming the folder — so the watcher dropped the only event there was
and every document inside stayed out of the index until something else
happened to invalidate the layer. Silent data loss, against a needless
re-index in the other direction.

Non-document extensions still filter (an image dropped into attachments/
costs nothing), and a path that is already gone counts as a change: the
index may well hold what used to be there. The stat runs after the
dot-entry filter, so .obsidian churn never reaches it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…alid

Three defects in one state machine, fixed as one model (index-keys.mjs):

  identity — what a layer reads. `kind` now has a reserved slot instead of
    sharing a flat object with the layer's own fields, where a field
    literally named `kind` (not reserved, and written by the desktop app's
    settings sync) overwrote it. Two layers then produced ONE identity,
    which was the index key, so one entry served both rows: a folder
    reported another source's documents as its own and won the merge with
    them. Every user field now nests a level down, and the null-prototype
    object keeps __proto__ stored rather than swallowed.

  validity — identity plus the policy that governed the read: the indexing
    settings and the credential epoch.

  key — validity plus the layer row, uniquified. Unique per row by
    construction rather than by argument.

Orphaned entries are now ADOPTED (moved to the new key) when their
validity still matches, instead of being mined for a snapshot and
restarted. A rename costs nothing at all, and because the move happens
inside openSources, no consumer can arrive "first" and miss it — the
handoff used to live in ensureIndexes alone, so a watcher-driven
invalidateIndex after a re-key blanked the source to 0 concepts. An entry
orphaned BY a policy change matches nothing and is dropped: lowering a
document cap no longer keeps serving the over-cap answer (forever, when
the re-index then failed), and Disconnect no longer keeps serving a
private repo's content.

Refreshing is now visible and bounded. A pass with a previous snapshot
reports `status: "ready"` plus an additive `indexing.refreshing`, so a
background re-read never flips a usable source — or the console's spinner
— back to unready; `awaitIndexes` waits on the pass rather than on status.
A pass dirtied mid-flight owes exactly one follow-up, and that follow-up
waits out a quiet period as long as the last pass took (1-15s), so
sustained editing costs one extra pass instead of an unbounded chain of
full re-walks. `indexing.passes` reports the count, which is the only way
to see a re-index storm from outside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
A user added a 3,000-note Obsidian vault to the shipped Mac app and it
"showed and then it just kind of hung there". The engine was fine. The UI
was lying: a still-indexing source rendered as "synced · 0 concepts", the
add wizard declared success off the POST alone, and a failing background
refresh gave up after three tries and said nothing at all.

Per-source truth
- adaptSources maps the engine's `status: "indexing"` to a real indexing
  state and carries `indexing.{phase, loaded, total, refreshing}` through,
  so "synced · 0 concepts" is now unreachable for a source being read.
- mergeSourceStatus folds a cheap status pass into the rows the views hold,
  so a Sources row tracks the toolbar instead of holding whatever phase the
  source started in.
- Warning chips count `warnings` (the true count) rather than
  `warningMessages.length` (capped at 10).

A shell-wide activity control
- The bare "Indexing N" badge becomes a compact progress affordance in the
  toolbar, visible from every destination, with a popover listing each task
  (source, phase, loaded/total, elapsed). Ready-and-refreshing is rendered
  as a quiet note, never as a spinner in front of data the user already has.

No more silent give-up
- The poll retries at capped backoff forever, keeps `indexingSources`, and
  exposes `refreshError` + `retryNow()` — a dismissible banner and an
  attention-toned indicator. `apiFetch` no longer awaits the desktop token
  without a deadline; one stalled IPC used to poison the whole session.

Cheap polling
- /api/status at 900ms (5s idle), heavy payloads only when the content moved,
  paused while the document is hidden. Measured on the 3,000-note vault:
  24 status calls and 2 resolve-alls, where the old loop issued 24 × 150MB.

The wizard confirms the add landed
- After the POST it polls /api/status until the named source appears and
  shows its live status card. A lost response and a 409 are now resolved by
  asking the engine, not by assuming; Finish no longer blocks on /api/graph.

Also fixes a pre-existing crash the silent give-up was hiding: a plain note
with no heading resolves to `heading: null`, and headingText called .replace
on it — which took the page down on any Obsidian-shaped vault.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
The suite asserted that a mid-index source is never described as finished,
but nothing drove the other half — a source actually reaching `ready`. That
gap is why a frozen "Scanning / Indexing" row read as a plausible product
bug: there was no test anyone could point at to say the poll path settles.

Drives App against one fake engine whose state advances and which answers
/api/status, /api/graph and /api/resolve-all from it, so the test cannot
pass by mocking the two routes into disagreeing. Asserts the rendered row
walks Scanning -> Reading N/3,000 -> 3000 concepts, that the toolbar
indicator clears itself, and that the heavy payload is fetched once at
bootstrap and exactly once more when the snapshot lands.

Verified to fail against three separate injuries: dropping the
mergeSourceStatus fold (row sticks on "Scanning"), disabling the refetch
gate (concepts never arrive), and retaining a finished task (toolbar
indicator never clears).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
@siracusa5
siracusa5 force-pushed the c/context-cake-source-audit-a3b7ea branch from d815c7a to cbef7d8 Compare August 7, 2026 13:19
siracusa5 and others added 7 commits August 7, 2026 09:20
…cy audit

CVE-2026-59870 (GHSA-5p4m-2wfm-xmqj) covers js-yaml 4.0.0-4.3.0, and the
lockfile pinned 4.3.0 transitively through electron-updater — a shipped
dependency, so `npm audit --omit=dev --audit-level=high` fails the desktop job
and with it the required gate.

4.3.1 carries the fix and already satisfies electron-updater's own `^4.1.0`
range, so this is a lockfile bump rather than an override: no package.json
change, no semver exception to remember later.

Unrelated to the rest of this branch; it is here because the advisory was
published against the existing tree and blocks CI on any branch that touches it.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
A documentation review of the branch found three statements in the root
CLAUDE.md that this work itself falsified, plus user-facing docs that describe
behavior that no longer exists.

Self-inflicted, in CLAUDE.md:
- "keyed by its layer config + settings" contradicted the bullet directly
  beneath it, which correctly says name and level are excluded and the
  credential epoch is included. Removed the superseded sentence.
- "carries a generation counter that moves whenever the graph payload would
  differ" overstated the contract: bumpGeneration deliberately excludes
  indexing.elapsedMs and passes, and the console additionally compares a
  content signature, so a generation move is necessary but not sufficient.
- "npm test ends with the eval" stopped being true when this branch appended
  three suites after it. The point of the gotcha — a ranking regression fails
  the build — is unchanged.

User-facing: the manifest reference now documents the per-document 2 MB ceiling
and the permission-skip warnings, and gains a section on what happens when a
layer is invalid, since a hand-edited manifest is exactly how someone meets
quarantine. /api/status is named where /api/graph already was, in the console
and playground READMEs and the playground tour.

Also: a manifest.mjs row in the key-file table (it gained two load-bearing
exports here), BackgroundActivity.tsx in the console's key files, and the
passes field on IndexProgress.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
The poll committed `generation` and `signature` before awaiting the heavy
read. A rejected readAll — a resolve-all timeout on a large vault, a
transient 500 — therefore left the gate looking satisfied: the next tick
saw nothing moved, skipped the retry, and took the success path, which
cleared the refresh banner and stamped lastRefreshAt. The console then
showed pre-edit concepts and conflicts for the rest of the session, since
contentSignature deliberately excludes document content and so nothing
reopens the gate on its own.

The gate now advances in exactly one place: past every await in readAll,
where the payload has actually landed. A refetch that was attempted and
did not land is remembered as owed, so a signature that flaps back to its
old value cannot close the gate either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
writeSectionApi read the target with fsp.readFile and no size check, while
the read and write file routes both gate on MAX_EDITABLE_BYTES. Measured
against a 30MB .md inside a layer root: PUT /api/section answered
{"ok":true} and left 64 bytes on disk. That is a document the indexer
refuses to read — one the cascade never served — destroyed by an editor
that was never able to show it.

The stat is already in hand, so the cap is checked before the read, with
writeFileApi's 413. Refusing beats skipping the layer: resolving a section
into every layer except the oversized one only mints a new partial
disagreement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
quarantineInvalidLayers rebuilt profiles with plain assignment, so a
profile named __proto__ set the prototype of the new container instead of
becoming an own key. Object.entries stopped seeing it, the re-validation
passed, and the quarantined read accepted a manifest the strict read
rejects — the app loading what the CLI and MCP paths refuse. A dropped
layer carrying a reserved key diverged the same way.

assertSafeKeys now runs before anything is copied, which lands the caller
on the authoritative strict error, and the profile accumulator uses
defineProperty so a reserved id could not reach the prototype even if it
got that far. Object.prototype was never polluted and the smuggled profile
was unreachable; this closes the divergence, not an escalation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
dismissedRefreshError was never cleared, so one Dismiss muted that wording
for the whole session — including a fresh outage after a clean recovery,
which usually reads identically ("socket hang up"). The comment beside it
already said dismissal was per message; now the recovery makes it so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
The main process answers `service?.token ?? ''`, so a service that is not
up yet resolves the token IPC with an empty string. desktopToken memoized
that as success: every later /api call went out unauthenticated, the
service 401'd each one, and nothing ever asked again. Not a bypass — the
engine still refuses — but it bricks the session with no retry path.

An empty token now throws, which drops the memo the same way a timeout
does, so the next call asks the main process again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
siracusa5 and others added 7 commits August 7, 2026 10:14
isIndexableFile asks the filesystem whether an event names a directory,
because inferring file-ness from path.extname swallowed folder-drop
events and every document inside stayed invisible. That part is right.
The statSync was not: it runs inside the fs.watch callback, on the event
loop of a service whose entire premise is that no slow source blocks it.
On an iCloud, Dropbox or SMB-backed vault — the same mounts that put the
per-source budget at 120s — one stat blocks every request for as long as
the mount takes to answer.

Same rule, awaited. onChange already defers its real work behind a 250ms
debounce, so the extra turn costs nothing, and it re-reads the watcher
entry afterwards in case the source was removed while the filesystem was
answering. The watch callback swallows a rejection rather than letting
one fs event take the process down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
buildGraph read its source rows before awaiting the resolve and computed
`generation` after it. A source whose index landed during that await
therefore arrived already accounted for in the number, while its row
still said "indexing, 0 concepts" — and the row's own progress block,
read live, said "ready" at the same time. The console stored that
generation, the next /api/status computed the identical one, `moved`
stayed false, and the refetch that would have shown the landed source was
never issued. The field report is "a source I added shows 0 concepts
forever."

pinEntry takes one observation of a source — snapshot, status, error,
progress, adapter health — and both the payload and the generation are
built from it. bumpGeneration now takes pinned observations rather than
live entries, so there is no way to compute a number for a state the
caller's payload does not contain. statusApi pins the same way (it is
synchronous, so nothing moves, but the two routes must agree on what a
generation means), and resolveAllApi pins for the same reason buildGraph
does: its loop spans many event-loop turns and `indexingSources` has to
name the state its concepts came from.

The test drives the window on purpose. A remote layer is held at its
first API call until the /api/graph request lands, which puts all of its
remaining round trips inside the resolve; the local layer is sized so
that loop yields far more times than the remote one needs to finish. The
assertion is the invariant, not the timing: when the payload disagrees
with what /api/status reports a moment later, the two generations must
differ, or a client can never learn the difference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
mergeSourceStatus folds a cheap /api/status pass into rows the views
already hold. It moved `status` and left `error` behind, so a source that
flipped to error between heavy refetches rendered the word "error" with
the block that says why still hidden — that block is gated on `error`.
Warnings had the mirror problem: they describe a snapshot, and a row with
none left the "indexed with N things left out" note hanging over from the
last good read.

`refreshing` is now normalized with `=== true`, matching adaptSources.
The two write the same field on the same row, and the documented "returns
the original array when nothing moved" identity contract only holds if
they agree on how a missing flag reads.

lastErrorAt/lastSuccessAt are deliberately untouched: /api/status carries
no health timestamps, and inventing one would be a worse lie than a stale
one. A status flip moves the engine's generation, so /api/graph fills
them in on the next pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…neration

`graph.generation` is optional on the wire. Against an engine that serves
/api/status without it, readAll left the gate at undefined on every pass:
`moved` was permanently true, and a settled, idle app pulled the whole
corpus back every five seconds.

The status route's number is the authority in that case — both routes
report the same counter, and it is the one the gate compares against — so
readAll takes it as a fallback from the poll that triggered the read.
`?? generation` alone does not close this: the branch that commits a
status generation is exactly the branch a permanently-true `moved` keeps
you out of, so the loop would have continued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
`if (idle) return null` runs during render, so when indexing finished the
focused button and its popover unmounted in the same commit — and a
focused node removed that way drops focus to <body>. A keyboard user
standing on the indicator was silently returned to the top of the
document, mid-task, with no event to explain it.

Retirement is now two steps inside a layout effect: notice `idle`, hand
focus to the next control in the toolbar (falling back to the header as a
last resort), then unmount. Both steps land before paint, so nothing
extra reaches the screen, and an app that has never had background work
still renders nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…us call

fetchStatus answered null for a 404, a 500, a socket error and its own
4s timeout alike, and the success step's watcher read that one answer as
"this engine has no /api/status": setWatched(null), return, done. One
blip three seconds into a 3,000-note index retired the live cards for the
rest of their life while the source was still reading.

probeStatus keeps the two apart. 'absent' is a property of the engine and
will not change while the wizard is open; 'failed' is a property of one
request and says nothing about the next, so the watch keeps its rows on
screen and asks again — capped at five consecutive failures so an engine
that is genuinely gone stops being polled.

The progress line also stops being a live region. It re-renders every
900ms, and role="status" meant a screen reader read the counter out on
every tick for the whole of an index; App.tsx states the opposite
doctrine and the activity popover's TaskRow already follows it. Now a
progressbar with aria-valuetext, so the number is taken on request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Quarantine made a hand-edited layer visible without making it fixable. Every
mutation route reads through mutateContextManifest, which reads STRICTLY, so
with an invalid layer present POST/PATCH/DELETE /api/sources and PATCH
/api/settings all threw before doing anything — including the one removal that
would have made the manifest valid again. The app could show you the problem
and nothing else.

repairContextManifest is the door that opens: the single mutation allowed to
READ a manifest holding an invalid layer, because it is the one that takes one
out. Nothing about what may be PERSISTED moves. The write is still
writeContextManifest, so the result must pass validateContextManifest in full;
a repair that does not leave the manifest valid is refused and the file on disk
is untouched. Both tolerant readers now share one private reader, so the door
can never accept a manifest the read path would reject — a duplicate layer
name, a second live layer, a broken profiles block all stay fatal. And a repair
may only remove: no layers array may come out of the callback longer than it
went in.

The callback gets the RAW manifest, not the quarantined one. Handing it the
cleaned manifest would delete every OTHER broken layer from the user's file as
a side effect of removing the one they asked about. For the same reason
quarantine records now carry `index` and removeSourceApi removes by index
rather than by name: a broken layer that also reuses a healthy layer's name is
shown as "seed (2)", and a name filter would have taken the healthy `seed` with
it.

?name= repeats, and that is not a convenience. Only a valid manifest may be
written, so with two invalid entries, removing either alone is refused — the
other still fails validation. Removing them in one transaction is the only
shape that both repairs the file and keeps the write strict; measured against
three bad layers, the single-name form is a dead end from which the app can
never recover. A request that asks for too little answers 409 naming what
blocked it, never a 500.

Console: rows carry `quarantined`, distinct from a source that failed to READ
(createErrorSource now takes the flag explicitly — the two were
indistinguishable from status alone, and only one of them can be acted on). On
an invalid row Rename and Sync are gone, since both could only fail; the panel
says the entry is not a working source, and Remove names every other invalid
entry before the click rather than quietly sweeping rows nobody selected.

PATCH /api/settings stays a strict write — quietly rewriting a manifest read
around a bad layer is how a hand-edited layer gets dropped without being asked
about — but it answered 500 with a layer validation error on the Settings
screen, which tells the user nothing about where to go. It now answers 409
pointing at Sources, where the repair lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
siracusa5 and others added 6 commits August 7, 2026 10:17
Review of the parent commit found the repair half-built. The write rewrites the
whole manifest and only a valid one is ever saved, so an invalid entry refuses
the removal of a perfectly healthy source exactly as it refuses the removal of
another invalid one. The engine already answered that 409 with the remedy —
name them in the same request — but the console only offered the sweep on an
invalid row, so a user with one bad layer could not remove anything at all and
was left staring at a message about a row that has nothing wrong with it.

Every removal now carries the invalid entries with it, named in the confirm
panel before the click. Same informed-consent shape as before, one row wider.

The service test gains the mixed case directly: a healthy source refused on its
own, then coming out together with the invalid entry beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Two independent changes answer "an open vault never settles" — onChange
stops forwarding events the walk would never read (isSkippedPath), and
invalidateIndex coalesces instead of cancel-and-restart. Assertion 1
gated only the second: with isSkippedPath deleted the suite stays fully
green, so a cleanup that dropped the filter would have shipped an open
vault re-walking every note on a timer.

Assertion 1b measures the filter on its own terms. indexing.passes is
already on the /api/graph row, so a settled source under churn it should
be ignoring has a pass delta of exactly zero. The churn is shaped to
isolate isSkippedPath from isIndexableFile: workspace.json is rejected by
both, but a directory under .obsidian is accepted by isIndexableFile
whatever the timing, so the dot-segment check is the only thing left.
Verified by reverting the guard against a scratch copy — 1b reports
"indexing.passes 1 -> 3" while assertion 1 still passes.

Same file, second problem: CONTROL_FLOOR_MS only suggested a bigger
fixture, so a runner faster than ~1.65x an M-series laptop turned a
healthy engine red until a human re-ran it by hand. The control now
rebuilds the corpus from its own measurement and re-times once; the loud
failure stays as the fallback if even that is too fast. A non-numeric
measurement now means 0 rather than 30000, which used to clear the very
floor it was meant to trip.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
sourceLanded polled /api/status four times and called absence `false`,
justified by a comment claiming the manifest write completes before the
POST returns. It does not: addSourceApi writes the manifest LAST, after
gitCloneOrPull and the MCP/github-rest probes. Add a large private repo,
apiFetch aborts at its 60s deadline while the clone runs on, and the
wizard threw "is not in the cascade, so nothing was added" — then the
clone finished, the source WAS added, and a retry reported "already
exists". Two contradictory messages for one successful add.

The three-valued answer already existed. A request that hit its own
deadline is now `null` (could not tell) rather than `false`, and the copy
for it says the add may still be completing and points at Sources instead
of asserting nothing happened. A definite request failure still means
absence is evidence, and still says so.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Three gate-suite landmines, all of the same shape: a healthy engine going
red for a reason that is not the engine.

graph-latency's MIN_WINDOW_MS=3400 sits 1.8x under a measured 6151ms
window, and the guard only SUGGESTED a bigger corpus — a runner that fast
left the build red until a human re-ran it by hand. The corpus is now
rebuilt from the measured window and re-taken once, replacing the
undersized one so assertion 2 still describes the graph it measures.
Lowering the floor would have re-opened the false-pass hole the floor
exists to close, so the floor stays and the fixture moves. The loud
failure remains as the fallback, and maxDocFiles gained headroom so a
scaled corpus cannot trip a doc cap instead.

setup-robustness asserted conceptCount == 2 BEFORE checking `id -u`, and
that count only holds because chmod 000 hides a file. Running as root —
the default in many CI images — failed with "readable documents missing"
and only then explained why. The root check now comes first and SKIPS the
two assertions that are literally about a permission error, running the
rest against the counts root actually produces (3 concepts, 1 warning).
Failing a healthy engine on root CI is how a suite gets muted.

index-keys had a comment teaching the opposite of the invariant: a rename
DOES re-key (index-keys.mjs puts the name in the key); the case
constructed changes level, and what makes a rename free is adoptIndexes
moving the entry.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Two accessibility defects in the toolbar's background-work control.

The trigger's aria-label was the full ticking description, rebuilt on
every 900ms poll. Several screen readers re-announce a focused button
whose accessible name changes, so a keyboard user standing on this
control was read a fresh sentence four times a minute for the length of
an index. The name is now built from the shape of the work — how many
sources, indexing or refreshing, failing or not — and never from
loaded/total/percent/elapsed, so it moves on a transition and not on a
tick. The counts stay on `title`, where they are hover detail.

Nothing announced a background REFRESH. The engine reports a refreshing
source as `ready`, so it never reaches load.indexingSources and App.tsx's
announcer never sees it: a sighted user watched this control appear and
fill while a screen-reader user was told nothing. A polite region here
now announces that one pair of transitions — never the aggregate ones the
shell already speaks, and never a tick. It is rendered whether or not the
control is, because a live region only announces content that changes
while it is already in the document, and "the refresh finished" is
exactly the transition where this control is on its way out.

The progressbar rows stay progressbars, with the reasoning written down:
a polite region around them would read the counter out on every tick.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…ea' into c/context-cake-source-audit-a3b7ea

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>

# Conflicts:
#	packages/core/tests/manifest.test.mjs
#	packages/core/tests/service-test.sh
@siracusa5
siracusa5 merged commit 2905f6c into main Aug 7, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant