fix: tana_semantic_search crash on null node name - #93
Conversation
enrichSearchResults copied the nullable nodes.name column into the result unchanged; isReferenceSyntax() then called .includes() on it, so any search whose result set included an unnamed source/container node crashed with 'null is not an object (evaluating name.includes)' and returned zero results. Coalesce null name to empty string at enrichment (the EnrichedSearchResult contract is string) and harden isReferenceSyntax() to treat null/undefined/empty as non-reference. Shared filter covers CLI, MCP tool, and webhook server. Reported via Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jcfischer
left a comment
There was a problem hiding this comment.
Sage code review — changes-requested
1 finding(s): 1 important.
HonestOracle
- [important]
CHANGELOG.md:10— Entrypoint coverage is asserted, not shown
The changelog claims, "Shared by the CLI, MCP tool, and webhook server, so all three are covered," but the diff only changessrc/embeddings/search-filter.tsand a unit test forisReferenceSyntax(). No cited call graph, integration test, or touched entrypoint proves those three runtime paths use this filter, so downstream readers could treat unverified coverage as confirmed.
Posted by Sage on Codex CLI substrate.
jcfischer
left a comment
There was a problem hiding this comment.
Sage code review — changes-requested
1 finding(s): 1 important.
HonestOracle
- [important]
CHANGELOG.md:11— Coverage claim lacks evidence
The changelog says, "Shared by the CLI, MCP tool, and webhook server, so all three are covered," but the diff only addsisReferenceSyntax(null | undefined | "")assertions and does not show CLI, MCP, or webhook coverage. This misleads downstream readers into treating integration behavior as verified when the PR only demonstrates helper-level behavior.
Posted by Sage on Codex CLI substrate.
jcfischer
left a comment
There was a problem hiding this comment.
Sage code review — changes-requested
1 finding(s): 1 important.
HonestOracle
- [important]
CHANGELOG.md:9— Unproven coverage across three entrypoints
The changelog says, "Shared by the CLI, MCP tool, and webhook server, so all three are covered," but the diff shows onlysearch-filter.tsand one unit test, with no cited call paths or integration evidence for those three entrypoints. This misleads release readers into treating endpoint coverage as verified when the PR data only demonstrates the shared helper behavior.
Posted by Sage on Codex CLI substrate.
Addresses sage review (changes-requested): the changelog claimed CLI/MCP/ webhook coverage but the diff only proved the shared filter (CLI + MCP). Found the webhook's convertSemanticResultsToTana had the same unguarded item.name/ancestor.name .includes on a nullable name (the FTS renderer right below already guarded with || '(unnamed)'). Coalesced both, so the three-path claim is now true and provable. Changelog reworded to cite the exact entrypoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jcfischer
left a comment
There was a problem hiding this comment.
Sage code review — commented
2 finding(s): 2 suggestion.
Maintainability
- [suggestion]
src/server/tana-webhook-server.ts:1274— Extract reference formatting helper
The node and ancestor paths duplicate the same shape: "if (name.includes("[[") && name.includes("]]"))" plus "const match = name.match(/[[.+?]]/)" and equivalent ancestor code. Keeping both branches in sync raises future-change cost in this renderer.
Fix: Extract a localformatNodeRef(name, id)helper and call it for both node and ancestor.
HonestOracle
- [suggestion]
src/server/tana-webhook-server.ts:1269— FTS parity claim is unshown
The comment saysmatches the FTS path, but this diff does not show the FTS renderer or prove it uses"(unnamed)"for nullable names. That makes the guard look behaviorally aligned with another path when the cited parity is not evidenced here.
Posted by Sage on Codex CLI substrate.
jcfischer
left a comment
There was a problem hiding this comment.
Sage code review — commented
1 finding(s): 1 suggestion.
Maintainability
- [suggestion]
src/server/tana-webhook-server.ts:1277— Duplicate reference rendering logic
The node and ancestor branches repeat the same shape:if (name.includes("[[") && name.includes("]]"))andif (ancestorName.includes("[[") && ancestorName.includes("]]")), then both run.match(/\[\[.+?\]\]/)and fall back to[[name^id]]. Future tweaks to Tana reference rendering now need mirrored edits in two blocks.
Fix: Extract a smallrenderNodeRef(name, id)helper and call it for both item and ancestor.
Posted by Sage on Codex CLI substrate.
jcfischer
left a comment
There was a problem hiding this comment.
Sage code review — commented
2 finding(s): 2 suggestion.
Maintainability
- [suggestion]
src/server/tana-webhook-server.ts:1276— Extract duplicated reference rendering
The node and ancestor paths now repeat the same branch shape:if (name.includes("[[") && name.includes("]]"))andif (ancestorName.includes("[[") && ancestorName.includes("]]")), followed by matchingmatch(/\[\[.+?\]\]/),trim(), and[[name^id]]construction. Keeping that logic in two blocks makes future syntax tweaks easy to apply to only one path.
Fix: Extract a smallformatNodeRef(name, id)helper and use it for both item and ancestor.
HonestOracle
- [suggestion]
src/server/tana-webhook-server.ts:1271— FTS parity is asserted, not shown
The comment says this null handling "matches the FTS path", but this same diff coalesces enrichment with "name: node.name ?? """ while the webhook renderer uses "const name = item.name || "(unnamed)"". That tells downstream readers these paths behave the same when the shown behavior is materially different.
Posted by Sage on Codex CLI substrate.
|
Sage re-review (after addressing first round) — verdict: commented, 0 blockers / 0 majors / 2 suggestions = effective pass. First round was changes-requested (changelog over-claimed webhook coverage). Addressed by hardening the webhook's Remaining 2 findings are non-blocking suggestions: (1) extract a |
Fix tana_semantic_search crash on null node name (#93). See CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jcfischer
left a comment
There was a problem hiding this comment.
Review verdict: commented (post-merge — PR already merged).
Tight, well-scoped fix. Three observations, all non-blocking.
1. Defensible layering of the fix
Coalescing node.name ?? "" in enrichSearchResults is the right place — it restores the EnrichedSearchResult.name: string contract before downstream stages (isReferenceSyntax, getDeduplicationKey) see anything. Hardening isReferenceSyntax(string | null | undefined) on top is genuine belt-and-suspenders, not duplicate defense, because the function is also exported and could be called from anywhere with looser typing. Good shape.
The DB row cast tightening to { name: string | null } matches the actual schema — that alone would have caught the bug at compile time if it had been there originally.
2. Webhook server parity is the easy-to-miss win
convertSemanticResultsToTana had the same unguarded .includes() pattern that convertSearchResultsToTana (FTS path) had already fixed with r.name || "(unnamed)". Bringing the semantic path to parity is the right call — without it, the same null would have crashed the renderer even after the enrichment fix (the webhook server doesn't go through enrichSearchResults for the rendered output; the null lives on the SemanticSearchResultItem directly). The comment "matches the FTS path" makes the intent clear to the next reader. Note the minor asymmetry: enrichment uses ?? (null/undefined only), the renderer uses || (also empty string → "(unnamed)"). Intentional and correct for each context — flagging it so future readers don't try to unify.
3. Minor — dedup collapse on empty names
With name: "" flowing through enrichment, getDeduplicationKey produces "|<tags>" for every unnamed node sharing a tag set, so deduplicateResults will keep only one. That's a behavioral change vs. pre-bug (when names were non-null in practice for results that reached dedup): multiple unnamed nodes now collapse into one result instead of throwing. Strictly better than crashing, and likely fine since unnamed source/container nodes weren't useful search results anyway — but if a user reports "I expected N unnamed results, only got one", this is why. Possible follow-up (not for this PR): either filter name === "" out alongside reference syntax in filterReferenceSyntax (rename it, or add a sibling filter), or include nodeId in the dedup key when name is empty. Worth a note in the issue tracker, not a blocker.
Test coverage
The regression test on isReferenceSyntax(null | undefined | "") is the right minimal coverage for the hardened guard. enrichSearchResults's null-coalesce isn't directly tested, but the contract is now narrow enough that the type system carries the weight — fine.
Nits
- The CHANGELOG entry is excellent — root cause, impact ("entire search fails"), shared-filter scope, and the webhook-server companion fix are all called out. Future-you will be grateful.
- The
bun test/bun run typecheckevidence in the description, plus the explicit "gate flake is #92, unrelated" note, makes the merge defensible without needing to re-run anything.
LGTM as merged.
jcfischer
left a comment
There was a problem hiding this comment.
Verdict: commented (post-merge). Tight, well-scoped fix — LGTM as merged. Independent observations below.
Layering is right
enrichSearchResults coalescing node.name ?? "" restores the EnrichedSearchResult.name: string contract before downstream consumers (isReferenceSyntax, getDeduplicationKey) see anything — that's the load-bearing fix. Hardening isReferenceSyntax(string | null | undefined) on top is real defense-in-depth (it's exported and callable from anywhere), not redundant. Tightening the DB row cast to { name: string | null } matches the actual schema and is what would have caught this at compile time originally.
Webhook server fix is necessary, not just hygiene
convertSemanticResultsToTana receives SemanticSearchResultItem[] directly — it does not flow through enrichSearchResults, so the enrichment coalesce does not protect this renderer. Without the webhook patch, a null item.name or item.ancestor.name would still crash the .includes() calls. Good catch.
"matches the FTS path" — verified
On the prior Sage HonestOracle finding that this comment is "unshown": it is actually shown — convertSearchResultsToTana at line 1324 of the same file already uses const name = r.name || "(unnamed)". The new semantic path now mirrors that exactly. Comment is accurate; the Sage finding was a false positive (it didn't read the neighboring function).
Minor — ?? vs || asymmetry is intentional
Enrichment uses ?? "" (only null/undefined). Renderer uses || "(unnamed)" (also empty string). That's correct for each context — the renderer wants "" to display as (unnamed), the enrichment contract just needs string. Worth not unifying.
Behavioral note — dedup collapse on empty names
With name: "" flowing through getDeduplicationKey, the normalized name is "" so the key becomes "|<tags>" for every unnamed node sharing a tag set. deduplicateResults will keep only one. Strictly better than crashing, but a subtle behavioral change vs. pre-bug: if a user reports "I expected N unnamed results, only got one", this is why. Possible follow-up (not for this PR): filter empty names out in filterReferenceSyntax (or sibling), or include nodeId in the dedup key when name is empty. File-level, not a blocker.
Minor — duplicated reference rendering in the webhook
Echoing the earlier Maintainability suggestion: the node and ancestor branches in convertSemanticResultsToTana are now structurally identical (includes("[[") && includes("]]") → match(/\[\[.+?\]\]/) → fallback [[${name}^${id}]]). A small formatNodeRef(name, id) helper would keep future tweaks in lockstep. Not a blocker for a one-file fix PR.
Tests
The isReferenceSyntax(null | undefined | "") regression is the right minimal coverage for the hardened guard. enrichSearchResults's coalesce isn't directly tested but the narrowed types carry the weight. Fine.
Changelog
Excellent entry — root cause, blast radius ("entire search fails"), shared-filter scope, and the webhook companion fix are all named. The earlier Sage "coverage claim unproven" critique was over-strict for a CHANGELOG note that describes which code path the fix touches, not "we ran integration tests on three entrypoints". Reads fine to a release-notes reader.
Posted by Claude (Opus 4.7) via cortex review pipeline.
jcfischer
left a comment
There was a problem hiding this comment.
Tight, well-scoped fix. Root-cause analysis in the description is accurate and matches the code: nodes.name is nullable, enrichSearchResults was casting it as non-null, the null then crashed isReferenceSyntax(r.name).includes(...) — and because Array.prototype.filter propagates the throw, one bad row killed the whole result set. Coalescing at the enrichment source plus a falsy guard in isReferenceSyntax is the right two-layer defense; getDeduplicationKey is now safe by construction.
The webhook-renderer side-fix in convertSemanticResultsToTana (lines 1272/1289) is a good catch — same crash shape would have hit the Tana paste path, and || "(unnamed)" is the right choice there (you also don't want a bare [[^nodeId]] from an empty string). CHANGELOG entry is accurate.
Nits (non-blocking)
-
Lingering same-shape cast in
src/mcp/tools/semantic-search.ts:298—as { name: string } | nullforSELECT name FROM nodes. Identical pattern to the one tightened insearch-filter.ts:67. No longer crashes thanks to the newisReferenceSyntaxguard, but aSemanticSearchResultItemwithname: nullcan still escape to MCP consumers (the interface declaresname: string). Worth a one-line tightening +?? ""for consistency in a follow-up. Thecontents.namepaths at lines 265/281 have the same caveat. -
Regression test only covers
isReferenceSyntaxin isolation. The reported crash was the integration: a NULLnodes.namerow flowing throughenrichSearchResults→filterReferenceSyntax. The added test guards the inner function only — anyone removing the?? ""fromenrichSearchResultslater would not trip a test. An in-memory sqlite test seeding a NULL-name row and asserting the pipeline returns an item withname === ""(and no throw) would lock the full contract. Minor. -
The renderer's
[[(unnamed)^nodeId]]output for null-name nodes is syntactically valid Tana paste but visually awkward; if these rows are typically not useful to surface, filtering them upstream (similar to how reference-syntax rows are filtered) would be cleaner than rendering a placeholder. Pure preference — out of scope here.
No blockers. Approving in spirit; using a comment-level post since the forge doesn't let me self-approve.
jcfischer
left a comment
There was a problem hiding this comment.
Cortex review — fix/semantic-search-null-name
Targeted null-safety fix; nicely diagnosed (nullable nodes.name typed as string) and addressed at the right layer (coalesce at construction in enrichSearchResults, plus belt-and-suspenders in isReferenceSyntax). The second commit catching convertSemanticResultsToTana in the webhook renderer is the right call — the changelog claim now matches the diff. PR is already merged, so the notes below are follow-up observations rather than blockers.
Observations
1. Same nullable-cast pattern still lives in src/mcp/tools/semantic-search.ts:298 (nit, latent)
.get(r.nodeId) as { name: string } | null
// …
name: node.name, // line 305 — node.name is actually nullable in DBThis is the exact pattern the PR just fixed in search-filter.ts:67. It doesn't crash today because the only known string-op consumer downstream (the webhook renderer) is now guarded, but the SemanticSearchResultItem.name: string contract is still a lie at this construction site — any future consumer that does .includes / .replace on the MCP tool's output will re-introduce the same class of crash. Worth tightening the cast to { name: string | null } and coalescing here too, for symmetry with the search-filter fix.
2. Regression test only exercises isReferenceSyntax (nit)
The new test proves the belt-and-suspenders layer, not the actual reported pipeline (enrichSearchResults → filterReferenceSyntax). A small test that pushes a RawSearchResult through enrichSearchResults with a stubbed DB returning { name: null } would lock down the primary fix (node.name ?? "" at line 91) against future regression. As-is, someone could remove the coalesce and only the secondary guard would save the test.
3. || vs ?? asymmetry between sites (informational)
Enrichment uses node.name ?? "" (line 91, only null/undefined collapse), webhook renderer uses item.name || "(unnamed)" (line 1272, also collapses empty string). The webhook side matches the FTS path right below it (r.name || "(unnamed)", line 1324) so the choice is intentional and reads consistently in context — flagging only because the difference isn't obvious from the diff.
Verdict
The fix is correct and ships safely; the changelog accurately bounds the blast radius (CLI / MCP search-filter path + webhook semantic renderer). Recommend a small follow-up to harden semantic-search.ts:298 so the nullable contract is honest at every construction site, not just the consumer guards.
— cortex review (automated)
jcfischer
left a comment
There was a problem hiding this comment.
Verdict: commented (post-merge). Fix is correct and well-scoped; layering of the coalesce at enrichSearchResults plus the falsy guard in isReferenceSyntax is the right two-layer defense, and the webhook companion patch is necessary (the renderer doesn't flow through enrichment, so a null item.name would still crash it). Prior post-merge reviews already cover the load-bearing observations; the notes below are confirmations and one item I don't see explicitly called out yet.
Confirmed prior observations (now persistent risk after merge)
-
Same nullable-cast pattern survives in
src/mcp/tools/semantic-search.ts:298. Identicalas { name: string } | nullfollowed byname: node.nameat line 305. This doesn't crash today (the only known string-op consumer downstream — the webhook renderer — is now guarded), but theSemanticSearchResultItem.name: stringdeclaration is a lie at this construction site. Any new consumer that does.includes/.replace/.matchon the MCP tool output re-introduces the exact class of bug this PR fixed. The right follow-up is to mirror the search-filter fix here: tighten the cast to{ name: string | null }and coalesce. Worth a follow-up issue. -
Regression test covers the belt, not the suspenders. The new
isReferenceSyntax(null | undefined | "")cases protect the secondary guard, but the primary fix —node.name ?? ""atsearch-filter.ts:91— has no direct test. Someone refactoringenrichSearchResultscould remove the coalesce without tripping a test, and the inner guard alone would carry it (until a future consumer skips the guard). A small in-memory sqlite test feeding a{ name: null }row throughenrichSearchResultswould lock the full reported pipeline.
One item I don't see flagged
filterReferenceSyntax now silently filters empty-name nodes too. Because name: "" is coalesced before the filter runs, and isReferenceSyntax("") returns false, unnamed source/container rows now flow through enrichment into the result set as { name: "", … }. That's strictly better than crashing, and the dedup collapse (already noted by a prior reviewer) keeps the noise down to one entry per tag-set, but filterReferenceSyntax will no longer drop unnamed nodes — they will appear in CLI/MCP results as empty-name entries. If unnamed nodes were previously "not surfacing" only because they crashed the filter, callers may notice their result lists grow. If they were being usefully dropped, that drop is gone. Worth a moment of thought on whether filterReferenceSyntax (or a sibling filter) should explicitly drop name === "" too — pure judgment call, not a blocker.
Minor
- The
??(enrichment) vs||(webhook renderer) asymmetry is intentional and reads correctly in context — the renderer wants""to surface as(unnamed), enrichment just needsstring. Worth not unifying. Prior reviewer already noted this. [[(unnamed)^nodeId]]renderer output for null-name nodes is syntactically valid Tana paste but visually odd; filtering null-name rows upstream of the renderer (as is done for[[…]]reference-syntax rows) would be cleaner long-term. Out of scope here.
Changelog
Accurate post the second commit — root cause, blast radius, scope (CLI/MCP via shared filter + webhook renderer) all named. The first-round Sage "coverage unproven" critique was over-strict for changelog text; the rewording satisfies it by citing concrete entrypoints.
LGTM as merged. Recommend opening a follow-up issue for the semantic-search.ts:298 cast/coalesce so the nullable contract is honest at every construction site, and consider whether unnamed nodes should be filtered out alongside reference syntax now that they survive enrichment.
Posted by Claude (Opus 4.7, 1M context) via cortex review pipeline.
jcfischer
left a comment
There was a problem hiding this comment.
Review — fix: tana_semantic_search crash on null node name
Targeted, well-scoped null-safety fix. Root cause is correctly identified (DB column nodes.name is nullable, but EnrichedSearchResult.name was typed string, so a null silently propagated to name.includes(...)), and the fix is layered correctly: stop the null at the type/data boundary in enrichSearchResults, and harden isReferenceSyntax as belt-and-suspenders. Webhook renderer's parallel .includes callsites are fixed too. Regression test is small but exactly on the failure point and includes a comment explaining the original error string, which is good for future archaeology.
Strengths
- DB row cast tightened to
{ name: string | null }(src/embeddings/search-filter.ts:67), so the type system would now catch a regression at the boundary instead of lettingnullleak in silently. - Source coalesce
node.name ?? ""atsrc/embeddings/search-filter.ts:91keeps theEnrichedSearchResult.name: stringcontract intact for all downstream consumers (isReferenceSyntax,getDeduplicationKey). isReferenceSyntaxguard atsrc/embeddings/search-filter.ts:32-34is correctly typed asstring | null | undefinedand uses a falsy check, so it also handles""correctly without a redundant call into.includes.- The webhook renderer change at
src/server/tana-webhook-server.ts:1267-1304extracts a single localname(andancestorName) and replaces every prioritem.name.*usage, which removes the risk of anitem.nameleft un-coalesced on one branch. The "matches the FTS path" comment correctly notes thatconvertSearchResultsToTanaalready used the same pattern at line 1324 — consistency restored. - CHANGELOG entry under
[Unreleased]is detailed and accurate.
Nits (non-blocking)
SemanticSearchResultItem.nameinsrc/mcp/tools/semantic-search.ts:35is still typedstring. In practice it's populated fromenrichSearchResults(now non-null), so this is fine at runtime, but the webhook's defensiveitem.name || "(unnamed)"is technically guarding against a state the type forbids. Either the type should bestring | null(matching the underlying DB reality and justifying the guard), or the guard is dead code. Tightening the type would have made the fix self-consistent across the two layers; leaving it for follow-up is reasonable.- After this fix,
enrichSearchResultswill emit results withname: ""for previously-crashing unnamed nodes.getDeduplicationKey("", tags)produces the key"|<tagkey>", so multiple distinct unnamed nodes with the same tag set will dedupe down to one — a behavior change vs. the (broken) prior state. Probably what you want (unnamed results aren't useful to surface twice), but worth noting since the PR description frames this purely as a crash fix. - The two fallback strings differ —
""in the enrichment layer and"(unnamed)"in the renderer. Contextually correct (structural vs. user-visible), but the asymmetry is easy to miss; a one-line comment in the renderer pointing at this would have been nice. Minor. - The renderer's else-branch at
src/server/tana-webhook-server.ts:1282produces[[(unnamed)^<nodeId>]]for a null-named node — a valid-looking Tana ref whose visible text is "(unnamed)". Acceptable, but if you ever expose this to Tana itself rather than just rendering it, that ref text becomes a node name elsewhere. Not in scope here.
Verdict
Approve. Small, surgical, correctly-scoped fix with the right kind of test. PR is already merged; comments above are for the record / follow-up if you want to harden the SemanticSearchResultItem.name type.
jcfischer
left a comment
There was a problem hiding this comment.
Verdict: commented (PR already merged) — targeted fix lands the reported crash, but leaves a related latent crash in the same MCP code path. Recommend a follow-up.
What's good
- Hardening
isReferenceSyntax(name: string | null | undefined)withif (!name) return falseis the right belt-and-suspenders move and prevents the exactname.includescrash from the bug report. - Coalescing
node.name ?? ""inenrichSearchResults(search-filter.ts) plus tightening the DB row cast to{ name: string | null }is exactly right for the CLI/filterAndDeduplicateResultspath — it makes theEnrichedSearchResult.name: stringcontract actually hold. - Defensive
item.name || "(unnamed)"(+ ancestor) inconvertSemanticResultsToTanamatches the FTS renderer's existing convention. Nice consistency. - Regression test added; CHANGELOG entry is detailed and accurate to scope.
Major — residual null-name crash in MCP basic mode (default path)
The PR description says "null never enters the pipeline", but that's only true for the shared enrichSearchResults. The MCP tana_semantic_search tool (src/mcp/tools/semantic-search.ts) has its own enrichment that does not go through enrichSearchResults, and it was not patched:
- In basic mode (
includeContentsdefaults tofalse— line 177), lines 295–308 querySELECT name FROM nodes …cast as{ name: string } | null, then setname: node.namestraight intoSemanticSearchResultItem.namewith no coalescing. The DB column is nullable, so a null name silently lands in the item — same root cause as the search-filter bug. - Line 338 (
!isReferenceSyntax(r.name)) no longer crashes thanks to the hardening — good — so the null-named row survives the filter. - Lines 342–349 then build
enrichedForDedupwithname: r.name(still null) and calldeduplicateResults(...), which invokesgetDeduplicationKey(result.name, result.tags)→name.replace(/^\s*-?\s*/, "").null.replacethrows — same user-visible failure mode as the original report (null is not an object (evaluating 'name.replace')), same trigger (a single unnamed node in the result set fails the entire search).
The PR description itself flags that "getDeduplicationKey would have crashed on .replace too," which shows the threat model was understood — it just wasn't applied to the MCP-tool-local enrichment.
Suggested follow-up (small):
// src/mcp/tools/semantic-search.ts ~line 295
const node = withDbRetrySync(
() => ctx.db.query("SELECT name FROM nodes WHERE id = ?").get(r.nodeId) as { name: string | null } | null,
"semanticSearch getNode"
);
if (node) {
const item: SemanticSearchResultItem = {
nodeId: r.nodeId,
name: node.name ?? "", // <— matches enrichSearchResults
...(getNodeContents already returns name: nodeResult.name || "(unnamed)" at show.ts:296, so the includeContents: true branch is already safe — only basic mode is exposed.)
Minor — type contract for SemanticSearchResultItem.name
SemanticSearchResultItem.name (and ancestor.name) are still typed string, but the webhook renderer now correctly assumes they can be falsy (item.name || "(unnamed)"). The PR tightened the DB row cast in search-filter.ts but didn't tighten this surface type. Loosening the type to string | null (matching reality) would have let the compiler flag both this new renderer site and the dedup gap above — likely caught the major finding at PR time.
Minor — regression test scope
The new test only exercises isReferenceSyntax(null | undefined | "") as a unit. It doesn't push a null-named row through enrichSearchResults → filterReferenceSyntax → deduplicateResults, so it would not have caught the residual MCP dedup crash above. Worth a single end-to-end fixture covering a result set with one null-named node, asserting non-zero results and no throw.
Nit (not actionable)
enrichSearchResults uses ?? "" while the webhook renderer uses || "(unnamed)". The semantics differ only for the literal empty string, which doesn't realistically occur here — stylistic only, mentioning for awareness.
Scope of the PR is right; verdict is commented because the immediate crash is closed but the same input can still kill the MCP path via the dedup step. Worth a quick follow-up PR before this gets re-reported.
jcfischer
left a comment
There was a problem hiding this comment.
Solid, minimal fix for the reported crash. Defense at the source (enrichSearchResults coalesce) plus the hardened isReferenceSyntax(string | null | undefined) signature is the right shape, and the regression test covers null/undefined/"". The webhook server's "(unnamed)" substitution also matches the convention already in convertSearchResultsToTana (FTS path), so the comment "matches the FTS path" is accurate.
A few observations worth flagging for follow-up — none block this PR:
Major — sibling enrichment path still type-lies on the same nullable column.
The PR body says "the filter is shared by the CLI, MCP tool, and webhook server, so all three paths are fixed." That's true for callers that route through enrichSearchResults, but the MCP tana_semantic_search tool (src/mcp/tools/semantic-search.ts) has its own enrichment that bypasses search-filter.ts. In the !includeContents branch it does the same trick:
// src/mcp/tools/semantic-search.ts:295-308
const node = withDbRetrySync(
() => ctx.db
.query("SELECT name FROM nodes WHERE id = ?")
.get(r.nodeId) as { name: string } | null, // ← same nullable-column type lie
"semanticSearch getNode"
);
if (node) {
const item: SemanticSearchResultItem = {
nodeId: r.nodeId,
name: node.name, // ← null can land here
...
};Same pattern in the includeContents branches via contents.name (L265, L281) and in the ancestor branches via entityAncestor.name / findMeaningfulAncestor / parentNode.name (L206, L218, L241) — and findNearestEntityAncestor in src/db/entity.ts:149 likewise declares name: string for a nullable column. Net effect: SemanticSearchResultItem.name is typed string but can be null at runtime.
The webhook server's item.name || "(unnamed)" is a downstream guard that prevents its crash, but the MCP tool's JSON response to its callers (Claude itself, in the MCP case) still emits null in a field whose schema says string. Any other consumer that does item.name.includes(...) reproduces the original bug. Worth a follow-up that tightens the row cast and coalesces at the same place you did here.
Nit — || vs ?? for empty-name handling.
search-filter.ts uses node.name ?? "" (preserves empty string as empty); the webhook server uses item.name || "(unnamed)" (substitutes on empty-string too). For the rendered output, "(unnamed)" is probably more useful than blank, so the inconsistency is defensible — just noting the two paths now disagree on what an empty (but non-null) name means.
Nit — enrichSearchResults coalesce isn't exercised by the test suite.
The new test covers isReferenceSyntax directly, which is great. The "stop null at the source" change in enrichSearchResults (the actual fix per the PR title) doesn't have a regression test of its own — a small fixture inserting a name = NULL row and asserting an EnrichedSearchResult.name === "" would lock in the contract.
Acknowledging this is already merged — flagging the semantic-search.ts parallel path as the main thing worth a follow-up.
jcfischer
left a comment
There was a problem hiding this comment.
Review of the null-name fix. PR is already merged, so flagging as comments for a follow-up.
Major — fix doesn't fully cover the MCP tana_semantic_search path
The PR description and CHANGELOG claim:
The shared enrichment/filter (
src/embeddings/search-filter.ts) is used by the MCPtana_semantic_searchtool and the CLI (search,embed), so both are covered.
That's not quite right. enrichSearchResults — the function where the source-of-truth coalescing (node.name ?? "") was added — is only called from src/commands/search.ts:500 and src/commands/embed.ts via filterAndDeduplicateResults. The MCP tool in src/mcp/tools/semantic-search.ts has its own enrichment loop and never calls enrichSearchResults; it only borrows isReferenceSyntax and deduplicateResults from the shared module. So the source-level coalescing does not protect the MCP path.
What the MCP path actually relies on is the belt-and-suspenders hardening of isReferenceSyntax. That defends the originally reported crash site (name.includes), but a null node.name from src/mcp/tools/semantic-search.ts:305 (basic mode, plus the analogous sites at :241, :265, :281, :206) still flows through filteredResults → enrichedForDedup (line 342–348) → deduplicateResults → getDeduplicationKey(result.name, ...) in search-filter.ts:42, where name.replace(/^\s*-?\s*/, "") throws the structurally identical null is not an object (evaluating 'name.replace') — i.e. the same defect, one stack frame over.
Because the webhook server's semantic endpoint (tana-webhook-server.ts:412) and the MCP tool both go through semanticSearch(), that crash kills the whole result set before convertSemanticResultsToTana is ever reached — so the renderer guard added in this PR is correct but downstream of the still-live crash.
Suggested follow-up: either coalesce at the MCP enrichment sites the same way (name: node.name ?? "") or harden getDeduplicationKey to accept string | null | undefined symmetrically with isReferenceSyntax. The latter is one line and matches the belt-and-suspenders pattern already adopted here.
Nits
getDeduplicationKeycollapses every coalesced-null name (no tags) to the key"|", so multiple distinct unnamed nodes in one result set will dedup down to one (highest-similarity wins). Pre-existing problem in spirit — these results used to crash — but worth confirming this matches intended behavior for the CLI path rather than e.g. keying onnodeIdwhen the name is empty.convertSemanticResultsToTanarenders unnamed nodes as[[(unnamed)^id]], which will appear as a literal "(unnamed)" link in Tana. Matches the pre-existing FTS path at line 1324, so consistency is fine; just flagging the user-visible string.- The regression test covers
isReferenceSyntaxdirectly but notenrichSearchResultsend-to-end with a nullnodes.namerow, nor the MCP/webhook path. A small fixture-DB test would have caught the gap above.
What's good
- Source-level coalescing in
enrichSearchResultsis the right shape — the inline comment explaining thestringcontract is exactly the kind of WHY-comment that earns its keep. - The DB row cast tightened to
{ name: string | null }is honest about the schema and makes the coalesce non-decorative. - Belt-and-suspenders on
isReferenceSyntaxis well-scoped and tested. - Webhook renderer fix mirrors the existing FTS path for consistency.
Verdict: comment — code change in this diff is sound, but the stated coverage of the MCP path overstates what was fixed; a small follow-up to coalesce in semantic-search.ts (or harden getDeduplicationKey) closes the remaining hole.
jcfischer
left a comment
There was a problem hiding this comment.
Review
Targeted, well-scoped fix with a clear root-cause analysis and a regression test. The CHANGELOG entry is good. Two findings — one residual crash path that the PR description claims is fixed but isn't, plus minor nits.
Major: MCP tana_semantic_search basic mode still crashes on null name (at dedup, not filter)
The PR description states "The filter is shared by the CLI, MCP tool, and webhook server, so all three paths are fixed" and explicitly calls out that "getDeduplicationKey would have crashed on .replace too." But the MCP tana_semantic_search tool's basic mode (the default — includeContents ?? false) does not flow through the shared enrichSearchResults. It builds SemanticSearchResultItem directly:
src/mcp/tools/semantic-search.ts:295-308
const node = withDbRetrySync(
() => ctx.db
.query("SELECT name FROM nodes WHERE id = ?")
.get(r.nodeId) as { name: string } | null, // cast lies — column is nullable
"semanticSearch getNode"
);
if (node) {
const item: SemanticSearchResultItem = {
nodeId: r.nodeId,
name: node.name, // null propagates here
...
};
}The trace from there:
src/mcp/tools/semantic-search.ts:338—results.filter(r => !isReferenceSyntax(r.name))— safe now (your hardening).src/mcp/tools/semantic-search.ts:342-348— maps toEnrichedSearchResultcarryingname: r.name(still possibly null).src/mcp/tools/semantic-search.ts:349—deduplicateResults(enrichedForDedup)callsgetDeduplicationKey(result.name, ...)per row.src/embeddings/search-filter.ts:42—name.replace(/^\s*-?\s*/, "")→ crashes with the analogousnull is not an object (evaluating 'name.replace').
So under the same workload that produced the reported bug, your fix turns a filter-step crash into a dedup-step crash. The bug class the PR description warned about is still live in the most-trafficked MCP path.
Two equivalent fixes; either is fine:
- A (mirror the enrichment fix): at
src/mcp/tools/semantic-search.ts:298, tighten the cast to{ name: string | null } | null, and at line 305 usename: node.name ?? "". This matches what you did inenrichSearchResults. - B (belt-and-suspenders, matches your
isReferenceSyntaxapproach): hardengetDeduplicationKeyinsrc/embeddings/search-filter.tsto acceptstring | null | undefinedand coalesce internally (e.g.(name ?? "").replace(...)). This is the more defensive option and protects any future call site that forgets to coalesce.
I'd suggest B in addition to A — the symmetry argument that motivated hardening isReferenceSyntax applies equally to getDeduplicationKey, and it makes the contract uniform across all of search-filter's entry points.
(contents.name from getNodeContents is already coalesced at src/commands/show.ts:296, so the includeContents branches at lines 263-291 are safe — only the basic-mode branch leaks null.)
Nit: cast inconsistency between the two identical SELECTs
You tightened the cast in src/embeddings/search-filter.ts:67 to { name: string | null } | null (good — it's truthful). The same SELECT name FROM nodes WHERE id = ? at src/mcp/tools/semantic-search.ts:298 still claims { name: string } | null. Worth aligning even if you take fix A above, just to stop the next reader from trusting the cast.
Nit: || vs ?? in the webhook renderer
src/server/tana-webhook-server.ts:1272 uses item.name || "(unnamed)" and similarly at line 1289. Means an empty-string name (e.g., from enrichSearchResults coalescing null ?? "") also renders as "(unnamed)". Probably the intent for a user-facing renderer, but it's a different convention from the ?? you used in enrichSearchResults (which preserves ""). Not a blocker — flagging only because the two coalesce strategies now disagree on what an empty name means.
Good things
- Root cause + symptom + fix described clearly, with the right pointer to the schema reality (
nodes.nameisTEXTwithoutNOT NULL). - Hardening
isReferenceSyntaxto accept null/undefined is the right defensive move and the regression test (tests/search-filter.test.ts:69-73) covers it directly. - Webhook renderer guard mirrors the bug class correctly for that path.
- The pre-push gate note about #92 flakes is exactly the right amount of context.
Suggested verdict
If this had been pre-merge, I'd request changes for the dedup-path crash — it's the same bug class the PR claims to have eliminated, in the most-used MCP code path. As a follow-up, a small patch coalescing node.name at semantic-search.ts:305 (and ideally hardening getDeduplicationKey) would actually close the report.
jcfischer
left a comment
There was a problem hiding this comment.
Targeted, well-scoped fix for the reported crash. Root cause is correctly identified at the type boundary (the DB-row cast was lying about nullability), and the defensive guard in isReferenceSyntax plus the webhook renderer hardening close the remaining paths. Regression test is appropriate.
A few observations:
Nit — PR description slightly misattributes coverage. The description says the shared enrichSearchResults/filterReferenceSyntax pipeline is used by both the MCP tana_semantic_search tool and the CLI, with the isReferenceSyntax null-guard framed as "belt-and-suspenders." In practice:
enrichSearchResults/filterAndDeduplicateResultsare only imported bysrc/commands/search.tsandsrc/commands/embed.ts(the CLI paths).src/mcp/tools/semantic-search.tshas its own enrichment (lines 252–329) that copiesnode.namestraight from the nullable DB column intoSemanticSearchResultItem.name, then callsisReferenceSyntax(r.name)at line 338.
So the null guard in isReferenceSyntax is actually load-bearing for the MCP tool path, not just defensive — without it the MCP tool would still have crashed. Worth correcting the framing for future readers/git blame.
Nit — latent type-contract violation in the MCP tool. Because src/mcp/tools/semantic-search.ts:298,305 casts the row as { name: string } | null and assigns name: node.name, MCP/LLM clients of tana_semantic_search can still receive items with name: null despite the SemanticSearchResultItem.name: string contract. No crash today (the guard in isReferenceSyntax handles it, and the webhook renderer now coalesces), but the same nullability bug exists at that boundary and will bite the next consumer that does string ops on item.name directly. Worth a follow-up to apply the same coalesce at the MCP enrichment site so the contract is honest at the API boundary.
Nit — coalesce-value inconsistency. enrichSearchResults coalesces null → ""; the webhook renderer coalesces null → "(unnamed)". Both are fine in isolation, but the empty-string coalesce in getDeduplicationKey will collapse all unnamed nodes sharing the same tag set into a single deduplicated entry (key becomes |tag1,tag2). Probably acceptable given how rare unnamed nodes in result sets are, but flagging in case dedup behavior for unnamed nodes matters.
Strong points. Fix at the type cast ({ name: string | null }) is the right layer — it makes the nullability visible to the type checker, so future edits can't silently regress. The inline comment at the coalesce site explains why (DB nullability vs. interface contract), which is exactly the kind of non-obvious WHY that's worth a comment. Webhook renderer hardening also covers item.ancestor.name, which had the same bug.
jcfischer
left a comment
There was a problem hiding this comment.
Review of fix: tana_semantic_search crash on null node name
Nice scope, clean diff, good root-cause writeup. One material concern about the completeness of the fix in the actual reported path.
🟠 Major — fix is incomplete on the default tana_semantic_search path; the same family of crash still reachable one step later
The PR fixes enrichSearchResults (in src/embeddings/search-filter.ts) and hardens isReferenceSyntax. But src/mcp/tools/semantic-search.ts — the MCP tana_semantic_search tool itself, i.e. the reported crash path — does not call enrichSearchResults. It has its own enrichment block (semantic-search.ts:295–310) for the default includeContents = false path:
const node = withDbRetrySync(
() => ctx.db
.query("SELECT name FROM nodes WHERE id = ?")
.get(r.nodeId) as { name: string } | null,
"semanticSearch getNode"
);
if (node) {
const item: SemanticSearchResultItem = {
nodeId: r.nodeId,
name: node.name, // ← still nullable at runtime; same root cause as the bug being fixed
...
};This is exactly the same pattern the PR identifies as buggy (typed string, nullable in DB) — just in a different file, untouched by this PR. includeContents defaults to false (line 177), so this is the default code path for the MCP tool that was reported.
Now trace a null-named node through the post-fix pipeline:
item.name = null(semantic-search.ts:305) ✓ still nullresults.filter(r => !isReferenceSyntax(r.name))(semantic-search.ts:339) — saved by the hardenedisReferenceSyntax, returnsfalse, item passes throughenrichedForDedup(semantic-search.ts:343–347) —name: r.namestill nulldeduplicateResults(enrichedForDedup)(semantic-search.ts:349) callsgetDeduplicationKey(result.name, …)getDeduplicationKeydoesname.replace(/^\s*-?\s*/, "")(search-filter.ts:43) →null is not an object (evaluating 'name.replace')
This is the same family of crash the PR description explicitly anticipated ("getDeduplicationKey would have crashed on .replace too") — and it is still reachable, because the source-side coalesce was only added in enrichSearchResults, not in semantic-search.ts's parallel enrichment.
The includeContents = true branch is incidentally safe because getNodeContents already coalesces name || "(unnamed)" (src/commands/show.ts:296). But that's the non-default branch.
Suggested fixes (either is sufficient; both is belt-and-suspenders consistent with isReferenceSyntax):
- Coalesce at the source in semantic-search.ts:305, matching the enrichSearchResults fix:
and tighten the cast on line 299 to
name: node.name ?? "",
{ name: string | null } | nullso the type tells the truth. - And/or harden
getDeduplicationKeyto acceptstring | null | undefinedand treat falsy as empty, exactly mirroring what was done toisReferenceSyntax. Without this, the next caller that buildsEnrichedSearchResults without going throughenrichSearchResultsre-introduces the crash.
The regression test (tests/search-filter.test.ts) covers isReferenceSyntax(null) directly but does not exercise the full pipeline with a null-name DB row, which is why this gap wasn't caught. A test that calls enrichSearchResults (or, better, the MCP tool path) with a mocked nullable row would surface it.
🟡 Nit — unnamed nodes now silently survive the pipeline
After the fix, enrichSearchResults produces name: "" for unnamed nodes. filterReferenceSyntax("") is false, so they pass the filter and end up in user-facing results — rendered as [[(unnamed)^id]] in the webhook renderer (correctly, after this PR) and as [[^nodeId]] from the CLI/MCP path (no (unnamed) fallback there). Two paths now disagree on the display of the same condition. A bare empty-string check in filterReferenceSyntax (or a dedicated filter step) would drop these uniformly — they're rarely useful to a user anyway. Optional; depends on UX intent.
🟡 Nit — webhook fallback uses || while enrichment uses ??
tana-webhook-server.ts uses item.name || "(unnamed)" (truthy check); search-filter uses node.name ?? "" (nullish check). Both are correct for the current types, but mixing the two operators for "null-name fallback" in the same PR is a small inconsistency. Either is fine; pick one.
What's good
- Root-cause analysis correctly identifies the typed-as-string-but-nullable-in-DB pattern.
- Tightening the DB row cast to
{ name: string | null }makes the type tell the truth — exactly the right move. - Hardening
isReferenceSyntaxwith an explicit early-return is appropriate defensive coding for a function that's exported and called from multiple places. - Webhook renderer is independently guarded — appropriate since it's a different consumer with different display semantics.
- Clear CHANGELOG entry that captures the symptom, the root cause, and the affected callers.
Verdict
Changes requested. The hardened isReferenceSyntax prevents the original error message, but the default tana_semantic_search path still throws on a null-named node — one step later, in getDeduplicationKey. Coalescing at semantic-search.ts:305 (and/or hardening getDeduplicationKey symmetrically with isReferenceSyntax) closes the gap.
Reported via Claude Code:
tana_semantic_searchintermittently crashed withnull is not an object (evaluating 'name.includes'), failing the entire search (zero results) whenever the result set happened to include an unnamed node. Retrying with a different query sometimes dodged the offending node.Root cause
enrichSearchResults(src/embeddings/search-filter.ts) copies thenodes.namecolumn straight intoEnrichedSearchResult.name. That column is nullable (source / unnamed container nodes), but the field is typedstring, so a null propagated silently. The next stage,filterReferenceSyntax → isReferenceSyntax(r.name), calledname.includes("[[")on it → crash.getDeduplicationKeywould have crashed on.replacetoo.Fix
node.name ?? ""at enrichment — null never enters the pipeline; thestringcontract holds. Tightened the DB row cast to{ name: string | null }.isReferenceSyntax()to acceptstring | null | undefinedand returnfalsefor falsy — belt-and-suspenders.The filter is shared by the CLI, MCP tool, and webhook server, so all three paths are fixed.
Test
tests/search-filter.test.ts:isReferenceSyntax(null | undefined | "")→false, no throw.bun test tests/search-filter.test.ts→ 9 pass.bun run typecheckclean.CHANGELOG
[Unreleased]updated.Gate note: local pre-push smoke gate intermittently fails on the pre-existing #92 timeout flakes (resolveEntity/node-builder temp-dir races) — unrelated to this one-file change; it passed clean on the push. CI runs the same suite.