Skip to content

fix: tana_semantic_search crash on null node name - #93

Merged
jcfischer merged 2 commits into
mainfrom
fix/semantic-search-null-name
Jun 12, 2026
Merged

jcfischer merged 2 commits into
mainfrom
fix/semantic-search-null-name

Conversation

@jcfischer

Copy link
Copy Markdown
Owner

Reported via Claude Code: tana_semantic_search intermittently crashed with null 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 the nodes.name column straight into EnrichedSearchResult.name. That column is nullable (source / unnamed container nodes), but the field is typed string, so a null propagated silently. The next stage, filterReferenceSyntax → isReferenceSyntax(r.name), called name.includes("[[") on it → crash. getDeduplicationKey would have crashed on .replace too.

Fix

  • Coalesce node.name ?? "" at enrichment — null never enters the pipeline; the string contract holds. Tightened the DB row cast to { name: string | null }.
  • Hardened isReferenceSyntax() to accept string | null | undefined and return false for falsy — belt-and-suspenders.

The filter is shared by the CLI, MCP tool, and webhook server, so all three paths are fixed.

Test

  • Added regression in tests/search-filter.test.ts: isReferenceSyntax(null | undefined | "") → false, no throw.
  • bun test tests/search-filter.test.ts → 9 pass.
  • bun run typecheck clean.

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.

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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 changes src/embeddings/search-filter.ts and a unit test for isReferenceSyntax(). 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 adds isReferenceSyntax(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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 only search-filter.ts and 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 local formatNodeRef(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 says matches 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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("]]")) and if (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 small renderNodeRef(name, id) helper and call it for both item and ancestor.

Posted by Sage on Codex CLI substrate.

@jcfischer jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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("]]")) and if (ancestorName.includes("[[") && ancestorName.includes("]]")), followed by matching match(/\[\[.+?\]\]/), 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 small formatNodeRef(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.

@jcfischer

Copy link
Copy Markdown
Owner Author

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 convertSemanticResultsToTana (same unguarded null-name .includes) and rewording the changelog to cite exact entrypoints — so the three-path claim is now true and provable.

Remaining 2 findings are non-blocking suggestions: (1) extract a formatNodeRef helper → filed as #94; (2) comment-wording quibble on FTS parity (the FTS renderer at line ~1319 does use || "(unnamed)"). Merging.

@jcfischer
jcfischer merged commit c901a0f into main Jun 12, 2026
1 check passed
@jcfischer
jcfischer deleted the fix/semantic-search-null-name branch June 12, 2026 07:43
jcfischer added a commit that referenced this pull request Jun 12, 2026
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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 typecheck evidence 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. Lingering same-shape cast in src/mcp/tools/semantic-search.ts:298 — as { name: string } | null for SELECT name FROM nodes. Identical pattern to the one tightened in search-filter.ts:67. No longer crashes thanks to the new isReferenceSyntax guard, but a SemanticSearchResultItem with name: null can still escape to MCP consumers (the interface declares name: string). Worth a one-line tightening + ?? "" for consistency in a follow-up. The contents.name paths at lines 265/281 have the same caveat.

  2. Regression test only covers isReferenceSyntax in isolation. The reported crash was the integration: a NULL nodes.name row flowing through enrichSearchResults → filterReferenceSyntax. The added test guards the inner function only — anyone removing the ?? "" from enrichSearchResults later would not trip a test. An in-memory sqlite test seeding a NULL-name row and asserting the pipeline returns an item with name === "" (and no throw) would lock the full contract. Minor.

  3. 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DB

This 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. Same nullable-cast pattern survives in src/mcp/tools/semantic-search.ts:298. Identical as { name: string } | null followed by name: node.name at line 305. This doesn't crash today (the only known string-op consumer downstream — the webhook renderer — is now guarded), but the SemanticSearchResultItem.name: string declaration is a lie at this construction site. Any new consumer that does .includes/.replace/.match on 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.

  2. Regression test covers the belt, not the suspenders. The new isReferenceSyntax(null | undefined | "") cases protect the secondary guard, but the primary fix — node.name ?? "" at search-filter.ts:91 — has no direct test. Someone refactoring enrichSearchResults could 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 through enrichSearchResults would 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 needs string. 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 letting null leak in silently.
  • Source coalesce node.name ?? "" at src/embeddings/search-filter.ts:91 keeps the EnrichedSearchResult.name: string contract intact for all downstream consumers (isReferenceSyntax, getDeduplicationKey).
  • isReferenceSyntax guard at src/embeddings/search-filter.ts:32-34 is correctly typed as string | null | undefined and 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-1304 extracts a single local name (and ancestorName) and replaces every prior item.name.* usage, which removes the risk of an item.name left un-coalesced on one branch. The "matches the FTS path" comment correctly notes that convertSearchResultsToTana already used the same pattern at line 1324 — consistency restored.
  • CHANGELOG entry under [Unreleased] is detailed and accurate.

Nits (non-blocking)

  1. SemanticSearchResultItem.name in src/mcp/tools/semantic-search.ts:35 is still typed string. In practice it's populated from enrichSearchResults (now non-null), so this is fine at runtime, but the webhook's defensive item.name || "(unnamed)" is technically guarding against a state the type forbids. Either the type should be string | 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.
  2. After this fix, enrichSearchResults will emit results with name: "" 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.
  3. 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.
  4. The renderer's else-branch at src/server/tana-webhook-server.ts:1282 produces [[(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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) with if (!name) return false is the right belt-and-suspenders move and prevents the exact name.includes crash from the bug report.
  • Coalescing node.name ?? "" in enrichSearchResults (search-filter.ts) plus tightening the DB row cast to { name: string | null } is exactly right for the CLI/filterAndDeduplicateResults path — it makes the EnrichedSearchResult.name: string contract actually hold.
  • Defensive item.name || "(unnamed)" (+ ancestor) in convertSemanticResultsToTana matches 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 (includeContents defaults to false — line 177), lines 295–308 query SELECT name FROM nodes … cast as { name: string } | null, then set name: node.name straight into SemanticSearchResultItem.name with 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 enrichedForDedup with name: r.name (still null) and call deduplicateResults(...), which invokes getDeduplicationKey(result.name, result.tags) → name.replace(/^\s*-?\s*/, ""). null.replace throws — 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MCP tana_semantic_search tool 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

  • getDeduplicationKey collapses 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 on nodeId when the name is empty.
  • convertSemanticResultsToTana renders 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 isReferenceSyntax directly but not enrichSearchResults end-to-end with a null nodes.name row, nor the MCP/webhook path. A small fixture-DB test would have caught the gap above.

What's good

  • Source-level coalescing in enrichSearchResults is the right shape — the inline comment explaining the string contract 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 isReferenceSyntax is 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. src/mcp/tools/semantic-search.ts:338 — results.filter(r => !isReferenceSyntax(r.name)) — safe now (your hardening).
  2. src/mcp/tools/semantic-search.ts:342-348 — maps to EnrichedSearchResult carrying name: r.name (still possibly null).
  3. src/mcp/tools/semantic-search.ts:349 — deduplicateResults(enrichedForDedup) calls getDeduplicationKey(result.name, ...) per row.
  4. src/embeddings/search-filter.ts:42 — name.replace(/^\s*-?\s*/, "") → crashes with the analogous null 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 use name: node.name ?? "". This matches what you did in enrichSearchResults.
  • B (belt-and-suspenders, matches your isReferenceSyntax approach): harden getDeduplicationKey in src/embeddings/search-filter.ts to accept string | null | undefined and 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.name is TEXT without NOT NULL).
  • Hardening isReferenceSyntax to 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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / filterAndDeduplicateResults are only imported by src/commands/search.ts and src/commands/embed.ts (the CLI paths).
  • src/mcp/tools/semantic-search.ts has its own enrichment (lines 252–329) that copies node.name straight from the nullable DB column into SemanticSearchResultItem.name, then calls isReferenceSyntax(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 jcfischer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. item.name = null (semantic-search.ts:305) ✓ still null
  2. results.filter(r => !isReferenceSyntax(r.name)) (semantic-search.ts:339) — saved by the hardened isReferenceSyntax, returns false, item passes through
  3. enrichedForDedup (semantic-search.ts:343–347) — name: r.name still null
  4. deduplicateResults(enrichedForDedup) (semantic-search.ts:349) calls getDeduplicationKey(result.name, …)
  5. getDeduplicationKey does name.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:
    name: node.name ?? "",
    and tighten the cast on line 299 to { name: string | null } | null so the type tells the truth.
  • And/or harden getDeduplicationKey to accept string | null | undefined and treat falsy as empty, exactly mirroring what was done to isReferenceSyntax. Without this, the next caller that builds EnrichedSearchResults without going through enrichSearchResults re-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 isReferenceSyntax with 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.

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