fix: harden the app against a simulated QA campaign - #110
Conversation
A QA pass driving the real app found the engine staying quiet where it should speak up, and one write path that trusted its callers. - add GET /api/search over the resolved cascade: the ranking search.mjs already gives MCP clients was unreachable over HTTP, so the console could only filter titles - a layer folder deleted underneath a running engine now reports "Layer folder no longer exists" instead of reading as an empty folder; a subdirectory vanishing mid-walk is still tolerated - count skipped dot-entries per source (skippedHidden) so a vault with content under a dot-directory is not silently short - track lastSuccessAt on the index entry: local sources have no health() of their own, so /api/graph reported "Not yet" beside a synced badge - PUT /api/file requires the modified timestamp it already documents, unless force: true — the guard was skipped whenever a caller omitted the field, which is exactly when it was needed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
A decision carries both its canonical discrepancyId and the legacy conflictId, and decisionMap indexes it under both. The resolved-record reconstruction then walked every key, so one section-content resolution came back as two resolved rows — a curator counting a cleared queue saw six rows for four decisions. Dedupe by canonical id; map insertion order already puts the canonical entry first. Also adds "target_missing" to the acknowledgement reason enum, so a broken link can be acknowledged as a target that does not exist yet rather than the catch-all "Other". The transaction tests only ever asserted failure and rollback, which is how a composed value that never replaced anything would have gone unnoticed; they now assert committed file content, and the frontmatter transaction path gets its first coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Provenance is the product's promise, and the cascade was rounding it off. - section provenance, "suppressed by", and dissent chips showed the lane name, so a value won by a second personal-level source read as "Personal" dissenting against "Personal". They name the source now; chip colour still comes from the lane. - lanes were a fixed level threshold: anything below 2 fell to Company, so a level-1 source sat in Company while the Team lane sat empty. Levels are ranked among those the manifest actually has, and lane headers list the sources behind them rather than implying one each. - Fit floored its scale at 0.2 while manual zoom floored at 0.4, so on a 3,000-concept vault Fit landed 100x too close and the first zoom-out jumped further in. Both clamp to the same epsilon now. - that canvas also rendered every concept as real DOM across a 726,134px row. Each lane caps at 250 nodes with a "Showing N of M" hand-off to Knowledge, which is where browsing everything belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
The compose field opened pre-filled with the value being replaced. Typing at the caret — which is what a click into a filled textarea invites — submitted the old text with the new text welded onto it, and the engine faithfully wrote that to every writable contributor. In a QA session it concatenated a section body and flattened a tags list. The field starts empty, with an explicit "Start from <source>" button for anyone who does want the existing value as a base. Compose is refused outright for list-valued frontmatter, matching the engine's guard, and labelled "Reconciled value" with no Markdown preview when the field is not Markdown. Also: the source filter ignored effectiveSource, hiding a resolved item from a filter naming the very source that won it; a search term stayed active across status tabs while the empty state said only "No discrepancies in this view", so a filtered-out item read as a cleared queue; the disposition radios share a name; and a broken link can be acknowledged with a reason that fits it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
A DOM-level audit of every interactive control found the naming and the focus trapping already correct; what was missing was what happens after. - the source-kind picker is a role=radiogroup of buttons, which get no native arrow keys. It follows the APG roving-tabindex pattern now. - closing either dialog dropped focus on <body>, so a keyboard user restarted from the top of the document. Both restore focus to whatever opened them, through one shared hook. - the shell behind an open dialog was reachable by a screen reader's virtual cursor even though the tab order was correctly trapped; it is inert while a dialog is open. - every view was titled "ContextCake", giving a single-URL app no way to announce that the view changed. - a source in an error state showed the error and "Last error: None" at once, and the indexing time budget rendered a bare 120000. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
The Knowledge search matched titles and ids, so a term sitting in a section body — the thing the agent-facing search ranks well — returned "No matching concepts" while the words were on screen. It calls the engine's search route now, debounced, keeping the instant substring match until ranked results land and falling back to it silently when the engine is older or unreachable. Demo mode stays local; there is no engine behind it. Also hardens the first paint: the poll loop pauses while the page is hidden, which is correct, but a page that first renders hidden never issued even one /api/status call, so a snapshot taken mid-index sat there with nobody asking whether the engine had finished. Bootstrap now probes once regardless of visibility, through the same gate the poll uses, so it only pays for a refetch when one is owed. Empty concepts say they are empty and offer a way to the file, instead of rendering a panel with nothing in it and no way out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Review of the search route caught it doing per-request what this codebase has a standing rule against: a full disk walk, a parse of every concept, and a BM25 build, on every call — 1.9s on a 3,000-note vault, unchanged on repeat, with the console calling it once per typing pause. It reads the background index snapshots now, through a view that answers from already-parsed concepts, and memoizes on the identity of the snapshots it read. A repeated query goes 1405ms → ~2ms. search.mjs is untouched, so the ranking the eval protects is byte-identical. Two more the same review caught, both mine: - the resolved-record dedupe checked what it had already emitted but not what was already live, so a decision indexed under its canonical and legacy ids emitted a phantom row carrying the CANONICAL id. Two rows, one id, in a list the console keys by id — worse than the duplicate it replaced. The test that was supposed to cover this built its fixture with no live conflict, so it asserted around the bug. - `health?.lastSuccessAt ?? entry.lastSuccessAt` also fires when a health block reports null, which is how github.mjs says "never built an index" — and it warn-and-continues on API failure, so the pass still resolves and stamps the entry. An unreachable private repo read "0 concepts · as of just now", asserting a read that never happened on the one row where staleness is the point. The compose guard now refuses any structured value, not just arrays; a map would have been mangled the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
The engine's search matches whole stemmed tokens, so "prim", "prima" and "primar" all return nothing on the way to "primary". Because any engine answer — including an empty one — replaced the substring filter, typing a title emptied the list and told the user there were no matches, then repopulated on the last letter. Engine hits now lead, in rank order, and substring matches follow; the two only disagree about ordering, and the union is empty exactly when both are, so the "no matches in titles or content" line is finally true when it appears. Stale hits are also cleared when the query changes, which the comment already claimed. The bootstrap probe added for a hidden first paint didn't do what its comment said either: one probe fires right after the initial read, and if the engine is still indexing at that instant — the case the comment describes — the loop is still parked and nothing resumes it. A hidden page now keeps polling while work is actually active, at a slower cadence; an idle hidden tab stays as silent as before, which is the part worth keeping, so it gets its own test. Also: the per-lane cap crashed the whole Cascade on a concept with no layers, where the layout it replaced had simply placed it off-canvas; and lowering the Fit floor had also lowered the manual zoom floor, letting the wheel reach a scale where nothing is visible and nothing suggests the way back. Fit keeps the small floor, manual zoom gets a usable one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Review pass: six defects found and fixedAn adversarial review of this diff found six confirmed defects — three of them regressions this branch introduced. All are fixed in Critical — the search route violated a standing rule in this repo. The discrepancy dedupe made things worse, not better. It checked what it had already emitted but never what was already live, so a decision indexed under both its canonical and legacy ids emitted a phantom row carrying the canonical id. Base produced two rows with different ids; this branch produced two rows sharing one — in a list the console keys by id and looks up with
Search made the Knowledge list worse before it made it better. The engine matches whole stemmed tokens, so "prim"/"prima"/"primar" return nothing en route to "primary" — and since any engine answer including The bootstrap probe didn't do what its comment said. One probe fires right after the initial read; if the engine is still indexing at that instant — the exact case the comment described — the loop is still parked and nothing resumes it. A hidden page now keeps polling while work is genuinely active, at a slower cadence; an idle hidden tab stays silent, which is the behaviour worth keeping, so it now has its own test. Two smaller ones: the per-lane cap crashed the entire Cascade on a concept with no layers, where the layout it replaced had merely placed it off-canvas; and lowering the Fit scale floor had also lowered the manual zoom floor, letting the wheel reach a scale where nothing is visible and nothing suggests the way back. VerificationConsole: 383 tests, typecheck and build clean. Engine: full suite green except two Deliberately left as follow-ups: the lane chips ( |
A second review round, aimed at the fixes from the first, found three places where this branch had made the engine less honest rather than more. - `/api/search` answered from whatever was indexed and said only "hits". On a 1,500-doc vault a query 3ms after start returned zero while /api/graph said `indexing: true`; 1.5s later the same query returned fifty. Someone launching the app on a real vault and typing would read that as an empty vault. It now reports `indexing`/`indexingSources` and honours `?wait=`, like the two sibling routes that already did. - the previous commit stopped `lastSuccessAt` fabricating a success, and in doing so taught it to disown a real one. A health block reports null both when a source has never been read and when *this adapter instance* has not read it — and adapters are rebuilt on every manifest change while index entries are adopted, so adding an unrelated folder made a healthy GitHub source report "never read" with zero new API calls. The entry stamp is the durable fact; it is now withheld when a health- bearing adapter says it is not ok, which is what made it untrustworthy in the first place, and trusted afterwards. - /api/files still read a deleted folder as an empty one, so the file browser offered "add a Markdown note and it appears here" about a folder that no longer existed. Same distinction okf-local already draws. Also: a query with no searchable token answered 500 rather than an empty result, and the TTL cache replayed every walk note except the hidden count, so a cached layer's skipped-file number flickered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Reviewing this branch's own fixes turned up three that had gone wrong in the same way: each solved the reported symptom and created a new one. - separating the Fit floor from the manual-zoom floor meant a Fit that legitimately landed at 0.023 was followed by a wheel notch — in either direction — snapping to 0.1 and exploding the canvas around the cursor. That is the bug an earlier commit in this branch had already fixed, and the test that described it was deleted along the way. There is one floor again, set where a card is still a shape rather than a sliver, and the per-lane cap is sized so a fitted view actually clears it. Fit now produces something you can read, and the first zoom after it moves the way you asked. - the hidden-tab poll added for a stuck first paint keyed off the last *successful* pass, so an engine that died mid-index left it retrying a corpse on a backgrounded tab with nothing able to stop it. A failed pass says nothing about whether work is in flight, and no longer keeps the loop alive. While hidden it also polls status only — learning that indexing finished without pulling the corpus down behind a hidden tab. - Escape restored focus twice: the shell closed the dialog and restored, then the dialog's own handler ran on the same event and restored again from an already-cleared ref, landing on a fallback element or nothing. Both handlers respect defaultPrevented now and the restore is idempotent. Confirmed in a real browser, which is where it failed and where the passing jsdom test could not see it. Also: a keystroke that changed only whitespace or case re-ran the search and blanked the ranked list, and when the engine answered precisely the ranked hits were indistinguishable from the substring matches beneath them, which now carry a heading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Second review round: four lenses aimed at the first round's fixesThe fixes in
Two more were honest gaps rather than regressions. The Escape-focus one is worth its own note, because it is the second time this round that jsdom and Chrome disagreed. A passing jsdom test said focus returned to the opener; in Chrome it landed on Also fixed: a query with no searchable token answered 500 instead of an empty result; a keystroke changing only whitespace or case re-ran the search and blanked the ranked list; the ranked hits were visually indistinguishable from the substring matches beneath them (they now carry a heading); the TTL cache replayed every walk note except the hidden count; and a concept with no layers wrote a stray What the reviews clearedMy own leading suspicion — that the new search memo was an unbounded cache holding a BM25 index per entry — was wrong, and measured to be wrong twice: capped at 200 (500 distinct queries → size 101), old generations dropped rather than accumulated (25 generations × 20 queries → final size 21), only small hit rows retained, rejections evicted, cleared on close, and no stale or removed-source content served (add → query → remove source → re-query returns One visible product change, and one thing I did not fixThe per-lane cap dropped from 250 to 24, because that is what it costs for a fitted view to clear a scale where a card is still a shape. On a 3,000-concept vault Fit now renders 25 readable nodes at scale 0.2 with "Showing 25 of 3,001 · Browse everything in Knowledge", where before it rendered 251 nodes at 0.0165 — a canvas the live reviewer described as blank apart from one faint dashed line. That is honest and usable, but it is a judgment call about how much a topology view should show, and the real answer for large vaults is wrapping lanes into rows rather than clamping harder. Worth your eye. Not fixed, flagged deliberately (pre-existing): a composed section body containing VerificationEngine: full suite green except the two pre-existing |
Summary
Phase 6 hardening from an automated QA campaign: six simulated user personas drove the real app — the console build the Mac app ships as its renderer, served over the engine against isolated sandboxes — clicking through first run, the discrepancy queue, a messy 3,000-note vault, deliberate config corruption, and a keyboard-only pass. Every UI claim was cross-checked against the engine API, so a UI/engine disagreement counted as a finding on its own. Thirty findings were triaged before any code changed; this PR is the resulting single fix pass.
Triage mattered more than the count suggests. Three of the loudest findings were not what they looked like: the console "never updating" was the deliberate pause-on-hidden poll behaviour meeting a QA browser tab that is always hidden; the "engine corrupts files on a reconciled answer" was the compose textarea pre-filling with the value being replaced, with the engine writing exactly what it was handed; and the "unnamed controls" accessibility finding was an artifact of the extraction tool rather than the DOM. Those three are fixed where the fault actually was, and the two accessibility claims that did not survive contact with a real browser were left alone.
Changes
effectiveSource; an active search silently survived a status-tab switch while the empty state implied a cleared queue.GET /api/searchexposes the rankingsearch.mjsalready served over MCP, and Knowledge calls it debounced, keeping the substring match until ranked results land and falling back silently on an older or unreachable engine.lastSuccessAtis tracked so a synced badge stops sitting beside "Last success: Not yet".PUT /api/filerequires themodifiedtimestamp it already documents unlessforce: true; the guard previously skipped itself whenever a caller omitted the field. No in-repo caller needed changing.document.title.Known limitation, not addressed here
Discrepancy resolution writes to a git-backed live layer through plain file writes rather than
git-core.mjs, leaving the working tree dirty and uncommitted — invisible to teammates and able to fail a later Sync'spull --ff-only. It predates this branch and the right fix is a design decision (exclude live layers from propagation, route the transactional path throughcommitPaths, or document it), so it is deliberately left for a follow-up rather than settled inside a QA fix pass.Affected area
Validation
npm testnpm --prefix apps/console run typechecknpm --prefix apps/console testnpm --prefix apps/console run buildnpm --prefix apps/site run build— not applicable; no site changes.npm testpasses in CI (engine job green). Locally it stops on twoservice-test.shassertions about agithub-restlayer pointed at an unreachable repo — pre-existing and environmental rather than from this branch: a worktree at the merge base produces the identical two failures in the same sandbox, while this branch adds 12 passing assertions in that same file (208 vs 196). Every later step the short-circuit skips was run individually and passes. The retrieval eval is unmoved (recall@1 0.895, recall@5 1.000, MRR 0.947). Console: 383 tests across 30 files, typecheck and build clean. Beyond the suites, the fixed build was driven live:/api/searchreturns ranked hits for body-only terms, compose on a list field 400s,PUT /api/filewithoutmodified400s, deleting a layer folder flips the source toerrorwith the folder named and restoring it recovers on its own, the 3,001-concept cascade renders 251 capped nodes and Fit reaches scale 0.016, and the conflict panel namesmaya-notesas the effective source.Compatibility
GET /api/searchand the additiveskippedHidden/lastSuccessAtfields extend existing payloads without changing index identity or validity, so no source re-indexes on upgrade.PUT /api/fileis the one behaviour change for API callers: a request omittingmodifiednow gets a 400 telling it what to send, orforce: trueto overwrite deliberately.Sign-off
git commit -s) per theDeveloper Certificate of Origin.
🤖 Generated with Claude Code