A Corpus Now Refuses to Show Another Corpus's Sources
Three corpora showed identical sources under three names. The data was right; the UI was answering a question it had stopped asking.
A Corpus Now Refuses to Show Another Corpus's Sources
Why Care?
If you curate research, the worst thing a tool can do is show you the wrong material confidently. Not an error page — those you notice. The wrong sources, under the right heading, with a plausible count beside them.
That is what the Corpora Curator was doing. Open Consumer Immunology and you saw three sources. Open Reinvented Fertility & Maternity: the same three. Open AI Infrastructure for Bioscience: the same three again. Each with its own name in the header and its own confident "3 sources" badge.
Only one of those was telling the truth.
What's New?
domain.assemblenow echoes back the(type, slug)it answered for. The reply says which corpus it describes.The curator refuses replies it did not ask for. A late or misrouted answer is dropped instead of rendered.
Sources clear the instant you switch corpora, before the fetch resolves — so the previous corpus's rows cannot linger under the new name.
Out-of-order replies can no longer win. Click three corpora quickly and you get the one you picked last, not the one the server answered last.
Four regression tests, and a test suite that had been silently dead is running again.
What we ruled out first
Two obvious suspects, both innocent.
The data. Queried production SurrealDB directly:
ai-infrastructure-for-bioscience 3
consumer-immunology 2
quantum-innovation-computational-biology 4
reinvented-fertility-maternity 1
specialized-foundation-models 5
wearables-and-somatic-markers 1
wellness-experiences 1Seven corpora, distinct source_uuids, no duplication. Clean.
The query. assembleDomain filters on
domain_type = $t AND domain_slug = $s AND client_slug = $c, and every write
path is scoped the same way. Correct.
The three titles on screen resolved to exactly one corpus:
ai-infrastructure-for-bioscience — the first one opened that session. The
server had been right all along; the screen was stale.
What was actually happening
The workspace-service log gave it away:
20:28:35 ws connect ×3
20:28:37 invoke ×4 answered ← ai-infrastructure-for-bioscience loads
20:30:39 ws close ×3
───── 28-minute dead window ─────
20:57:20/26/33 ← three corpora clicked. No socket. No invokes.
20:58:43 ws connectEvery click landed in a window with no WebSocket — the signature of a slept laptop or a long-backgrounded tab, where browsers throttle the reconnect timers.
And select() was written in the order that makes that fatal:
this.activeSlug = slug; // header updates instantly
const r = await this.call('domain.assemble', …); // queued; socket is closed
this.sources = (r?.sources ?? []).map(withSlug); // never runsIdentity moved. Content did not. Nothing detected the disagreement, because nothing knew they were supposed to match — a list of sources carried no trace of the corpus it came from, so the UI could not have noticed it was wrong.
The fix: make the wrong state unrepresentable
Clearing sources before the await stops the symptom. It does not stop the
class. Two rapid switches on a slow link can still resolve out of order, and
last-write-wins means whichever reply landed last, not whichever corpus you
picked.
So the reply now carries its own identity, and the client checks it:
if (this.activeSlug !== slug) return; // superseded by a later pick
if (!replyMatches(r, wantType, slug)) { … } // answer to a different questionThe information already existed — source_usages has domain_slug — it was
just being dropped on the way out.
One deliberate softness: the match is enforced only when the echo is
present. record-surrealdb-resolver and the curator deploy independently, so a
resolver still running the old build sends no echo. Rejecting that would blank
the surface entirely — trading a subtle wrong-data bug for a total outage. Once
both sides are everywhere, it can tighten.
A dead test suite, found by accident
Adding tests surfaced something else: apps/corpora-curator/test/curation.test.ts
had not run since gh #93 centralised the WS endpoint. That commit added
resolveWsUrl() at module scope; the test's mock of @augment-it/workspace
never gained the export, so importing the module threw.
Vitest reports that as "no tests" — not as a failure. Eight tests vanished and the output never said anything was wrong.
Which is the same shape as the bug we came to fix, and as the deploy watchdog's own guard against it:
An empty service list must NEVER read as healthy.
A curator that renders an unanswered fetch as content. A suite that reports zero tests as fine. Absence of signal rendered as positive signal, three times in one codebase. Worth naming as a class, not three incidents.
Mock repaired, suite alive: 12 passing.
Under the Hood
✓ a reply carrying a different slug is refused, not rendered
✓ switching corpora clears the previous sources before the reply arrives
✓ a superseded selection does not overwrite the corpus that replaced it
✓ a resolver that does not echo (type, slug) is still trustedsourcesStatus (idle | loading | ready | error) now tracks whether sources
actually describes activeSlug, so the surface can tell "empty" from "unknown".
Verification: resolver typecheck clean, curator builds, 12 curator tests and 21
resolver tests green. The ten pre-existing gallery/catalog.ts type errors are
untouched and unrelated — confirmed identical with and without this change.
What's Next
Two layers from the plan remain:
Loading and error states rendered in the curator. The state exists now; the pane still has no connection indicator, while the shell's
WorkspaceSwitcherhas had one all along.Reconnect on
visibilitychange/onlineinstead of throttled backoff timers — the change that shortens the 28-minute dead window itself. It lives intransport.ts, which all five remotes bundle separately, so it needs all five rebuilt.
References
[[Stale-Corpus-Sources-When-The-Workspace-Socket-Is-Down]] — the plan
[[Webhook-As-Wake-Up-Not-As-Truth]] — the same don't-trust-what-you-can't-verify discipline, one layer up
