Skip to content

fix: harden the app against a simulated QA campaign - #110

Open
siracusa5 wants to merge 10 commits into
mainfrom
c/automated-qa-testing-c29277
Open

fix: harden the app against a simulated QA campaign#110
siracusa5 wants to merge 10 commits into
mainfrom
c/automated-qa-testing-c29277

Conversation

@siracusa5

@siracusa5 siracusa5 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

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

  • Provenance — the cascade labelled sections and dissent by lane, so a value won by a second personal-level source read as "Personal" disagreeing with "Personal". Sections, suppressions, and dissent chips name the source; lane colour still comes from the lane.
  • Lanes — level buckets were fixed thresholds, so a level-1 source rendered under Company while the Team lane sat empty. Levels rank among those the manifest actually contains, and lane headers list the sources behind them.
  • Cascade at size — 3,000 concepts rendered as real DOM across a 726,134px row, with Fit floored at a scale 100x too close and zoom-out floored higher still. Both clamp together; each lane caps at 250 nodes with a "Showing N of M" hand-off to Knowledge.
  • Reconciled answers — the compose field starts empty with an explicit "Start from <source>" opt-in, and is refused for list-valued frontmatter on both sides (the engine 400s; the console disables it and says why).
  • Discrepancy ledger — a decision indexed under both its canonical and legacy id emitted two resolved rows; the source filter ignored effectiveSource; an active search silently survived a status-tab switch while the empty state implied a cleared queue.
  • SearchGET /api/search exposes the ranking search.mjs already 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.
  • Honest source state — a deleted layer folder reported as an empty one ("Add a Markdown note and it appears here" over a folder that no longer exists); skipped dot-entries are counted; lastSuccessAt is tracked so a synced badge stops sitting beside "Last success: Not yet".
  • Write safetyPUT /api/file requires the modified timestamp it already documents unless force: true; the guard previously skipped itself whenever a caller omitted the field. No in-repo caller needed changing.
  • Keyboard and screen reader — APG roving tabindex on the source-kind radiogroup, focus returning to whatever opened a dialog, the shell inert behind an open dialog, and a per-view 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's pull --ff-only. It predates this branch and the right fix is a design decision (exclude live layers from propagation, route the transactional path through commitPaths, or document it), so it is deliberately left for a follow-up rather than settled inside a QA fix pass.

Affected area

  • Core engine / MCP / write path
  • Console
  • Site
  • Playground or local demo surfaces
  • Docs / specs / contributor workflow
  • Packs

Validation

  • npm test
  • npm --prefix apps/console run typecheck
  • npm --prefix apps/console test
  • npm --prefix apps/console run build
  • npm --prefix apps/site run build — not applicable; no site changes.

npm test passes in CI (engine job green). Locally it stops on two service-test.sh assertions about a github-rest layer 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/search returns ranked hits for body-only terms, compose on a list field 400s, PUT /api/file without modified 400s, deleting a layer folder flips the source to error with 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 names maya-notes as the effective source.

Compatibility

  • Root commands still work, or the PR explains the migration path.
  • No root npm dependencies were added.
  • Generated files are ignored or intentionally committed.

GET /api/search and the additive skippedHidden / lastSuccessAt fields extend existing payloads without changing index identity or validity, so no source re-indexes on upgrade. PUT /api/file is the one behaviour change for API callers: a request omitting modified now gets a 400 telling it what to send, or force: true to overwrite deliberately.

Sign-off

🤖 Generated with Claude Code

siracusa5 and others added 8 commits August 8, 2026 16:30
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>
@siracusa5

Copy link
Copy Markdown
Collaborator Author

Review pass: six defects found and fixed

An adversarial review of this diff found six confirmed defects — three of them regressions this branch introduced. All are fixed in 591e77f and 0a6d71b. Worth recording what they were, because the pattern is the point: fixes written against specs need the same adversarial pass the original findings got, and in two cases the regression test written alongside the fix asserted around the bug rather than at it.

Critical — the search route violated a standing rule in this repo. GET /api/search did a full disk walk, a parse of every concept, and a BM25 build on every request: 1931ms on a 3,000-note vault, 2053ms on repeat (no memoization), ~475MB RSS after six calls — with the console calling it once per typing pause, so the sixth answer landed 7.5s after typing stopped. That is exactly the invariant CLAUDE.md records from the countTokens incident, and search is heavier. It now reads the background index snapshots through a view over already-parsed concepts and memoizes on the identity of the snapshots it read, with the promise stored before it settles so single-flight comes for free. Repeat query: 1405ms → ~2ms. search.mjs is untouched, so the ranking the retrieval eval protects is byte-identical.

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 find(c => c.id === …). A live "reopened" row and a stale "resolved" row became indistinguishable. The test meant to cover it built its fixture with no live conflict, making the broken branch unreachable.

lastSuccessAt claimed a read that never happened. health?.lastSuccessAt ?? entry.lastSuccessAt also fires when a health block reports null — which is precisely how github.mjs says "never built an index" — and that adapter warn-and-continues on API failure, so the index pass still resolves and stamps the entry. An unreachable private repo rendered as "0 concepts · as of <just now> · <403>", on the one row where staleness is the entire point.

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 [] replaced the substring filter, typing a title emptied the list and claimed there were no matches. Engine hits now lead in rank order with substring matches following; the union is empty exactly when both are, so the "no matches in titles or content" message is true when it appears. Stale hits are also cleared on query change, which the comment already claimed.

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.

Verification

Console: 383 tests, typecheck and build clean. Engine: full suite green except two service-test.sh assertions about a github-rest layer pointed at an unreachable repo — verified pre-existing, not caused by this branch: a worktree at the merge base (65325a6) produces the identical two failures in this sandbox, while this branch adds 12 passing assertions in that same file (208 vs 196). They pass in CI, which has network. Retrieval eval unmoved (recall@1 0.895, recall@5 1.000, MRR 0.947).

Deliberately left as follow-ups: the lane chips (LayerChip, ConceptDetail) still print Personal/Team/Company while the lane headers now name real sources and levels — consistent, but only half honest; and target_missing is accepted as an acknowledgement reason for any discrepancy kind server-side, gated to broken links only in the UI, which matters because a rule carrying that reason can be promoted to automatic.

siracusa5 and others added 2 commits August 8, 2026 18:28
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>
@siracusa5

Copy link
Copy Markdown
Collaborator Author

Second review round: four lenses aimed at the first round's fixes

The fixes in 591e77f/0a6d71b had never been independently reviewed, so four reviewers went at them — engine-deep, console-deep, security, and one driving the real app. They found seven more defects. Three of them were fixes that had solved the reported symptom and created a new one, which is the finding worth reading:

  • The zoom floor. Splitting the Fit floor from the manual-zoom floor meant a Fit that legitimately landed at 0.023 was followed by one wheel notch — in either direction — snapping to 0.1 and exploding the canvas around the cursor. That is precisely the bug an earlier commit on this branch had already fixed, and the test whose own wording described it ("zooming out after a Fit… snapped the view back up — the opposite of 'zoom out'") was deleted in the process. Two reviewers caught it independently, one by arithmetic and one in Chrome (Fit 0.0165 → one click → 0.1, a 6× jump).
  • lastSuccessAt. Stopping it fabricating a success 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 hasn't read it — and adapters are rebuilt on every manifest change while index entries are adopted. Measured: healthy GitHub source, add an unrelated local folder, zero new API calls, still serving three concepts, timestamp flips to null. The durable fact is the index entry; it's now withheld when a health-bearing adapter says it isn't ok (which is what made it untrustworthy) and trusted after that.
  • The hidden-tab poll. It keyed off the last successful pass, so an engine dying mid-index left it retrying a corpse forever on a backgrounded tab — 85 failed fetches in ten simulated minutes with no termination condition. It also took the heavy /api/graph + /api/resolve-all path on every hidden poll, which is exactly what that gate exists to prevent.

Two more were honest gaps rather than regressions. /api/search answered from a partial index while reporting only hits and ignoring ?wait= — on a 1,500-doc vault a query 3ms after start returned zero while /api/graph said indexing: true, and 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. And /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.

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 <body>. The real cause was a double restore — the shell closed the dialog and restored focus, then the dialog's own Escape 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.

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 NaN into a lane header count.

What the reviews cleared

My 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 []). The security pass independently confirmed the no-leak result, and also cleared: /api/search sits behind the same bearer/CSRF gate as its siblings; force: true bypasses only the mtime check, with traversal, symlink escape, unknown-layer and non-text-extension all still enforced; limit validation is solid and an oversized query is rejected before it reaches the handler. The debounce race, the timer scheduling across rapid hide/show cycles, and the discrepancy dedupe were each attacked and held.

One visible product change, and one thing I did not fix

The 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 ## Heading {#anchor} or a --- block is spliced into the document raw, creating a new anchored section and an embedded frontmatter-looking block in every writable contributor — content an agent later reads as authored structure. Confirmed by exploit. It shipped in #106 and this branch only touches the neighbouring frontmatter guard (frontmatter values are safe — renderScalar escapes them). It belongs with the live-layer git gap in a pass of its own on the write path.

Verification

Engine: full suite green except the two pre-existing service-test.sh assertions about an unreachable github-rest layer, unchanged from the merge base and passing in CI. Console: 395 tests, typecheck and build clean. Every new or changed test was confirmed to fail against the pre-fix code. Retrieval eval unmoved. Beyond the suites: Fit verified at 0.2 with no jump on the next zoom in either direction, /api/search confirmed returning indexing/indexingSources, and a token-free query confirmed answering 200 with no hits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant