You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A tactical, no-regret fix for the NodeFilter silent-drop bug class from issue #117, designed to be shippable in one small PR without committing to the broader strict/permissive/hybrid frame decision.
The principle: accept multiple equivalent input forms (lossless) — reject inputs we'd silently discard (lossy).
This frames the bright line between "convenience for the agent" and "bug class": lossless transformations of input are fine; lossy silent-drops are not. The frame question of #117 (strict vs. permissive vs. hybrid) is deferred — this issue only commits to "stop silently dropping things."
Why this exists
Issue #117 surfaces a real frame question with real tradeoffs, and the user explicitly asked for thinking-time before locking. But the production-blocking bug class (silent-drop of inapplicable filter fields) doesn't have to wait for the frame decision. It can be fixed cheaply, today, without committing to a frame.
This issue is the "ship the safety net first" path. If the eventual frame decision in #117 is strict: this work is the first migration step. If the eventual decision is permissive: this work is a no-cost compatibility safety net. If the eventual decision is hybrid: this work is half of step 1.
Lossless-permissive doesn't pick a side. It just stops the bleeding.
The principle, concretely
Behavior
Today
Under lossless-permissive
filter passed as JSON-encoded string
Decoded losslessly via _coerce_filter
Kept (lossless: same input, different form)
filter passed as dict or NodeFilter
Accepted
Kept (lossless: same input, different form)
Unknown key in filter (e.g. "kind": "client" inside filter)
Silently dropped by Pydantic extra="ignore"
Rejected with loud error (lossy → reject)
Symbol-only field (e.g. fqn_prefix) sent with kind="client"
Silently dropped — returns every Client
Rejected with structured error listing applicable fields for the kind (lossy → reject)
Field that does apply to the requested kind
Honored
Honored (no change)
That's the whole change. No new fields, no aliases, no smart behaviors. Just: the contract about what gets honored vs. discarded becomes explicit instead of implicit.
Permissive frame is still reachable — relax extra="forbid" later, add field aliases per kind, add smart behaviors per request
Hybrid frame is still reachable — combine the above per-tool
None of those future doors close. The only door that opens is "no more silent-drop bugs of the #117 shape." That's the safest possible move because it eliminates the empirically-bad behavior without committing to a long-term frame.
Per-kind applicability check in find_v2 / search_v2 / describe_v2 / neighbors_v2. Before the Cypher push-down, validate that the populated fields on nf are a subset of the fields applicable to kind. Non-applicable fields → FindOutput(success=False, message=…) with a structured error.
Error messages designed as teaching surfaces, e.g.:
filter field 'fqn_prefix' is not applicable to kind 'client'.
applicable fields for kind 'client': ['microservice', 'module', 'client_kind',
'target_service', 'target_path_prefix', 'client_method', 'source_layer'].
_coerce_filter stays untouched. JSON-decoding is lossless multi-form input — same input, different form. Not a permissive-frame thing; just a serialization convenience.
No changes to search.query permissiveness. The fuzzy query → ranked score behavior is unaffected. This issue is about filter contract honesty only.
Test coverage
Unknown key in filter → ValidationError
Symbol-only field with kind="client" → success=False with structured message
Symbol-only field with kind="symbol" → honored (no change)
Client-only field with kind="symbol" → success=False
Empty filter on any kind → success, no-op (no change)
JSON-string filter input → still works (lossless multi-form)
Does not change search.query semantics. Query stays fuzzy text.
Does not introduce field aliases (no member_fqn_prefix for clients, no smart target_service resolution, etc.). Smart behaviors stay deferred.
Does not change EdgeType literal, kind literal, or any other shipped strict invariant.
The caveat (deliberately surfaced)
Lossless-permissive is a tactical fix, not a strategic frame. It answers "stop silently dropping things" but it does not answer "what's the contract per kind?" The deferred question still has to be answered eventually, and the longer we wait, the more agents have been trained on the loud-fail-but-still-permissive surface, which biases future frame choices.
Concrete risk: if we ship lossless-permissive and then 3 months later try to add per-kind field aliases (toward permissive frame) or per-kind closed enums (toward strict frame), we'll have agent prompts in the wild that assume the current surface. They'll need updating. The longer we wait, the more agents need updating.
So the safest path isn't "ship lossless-permissive and forget about it" — it's "ship lossless-permissive AND keep #117 open as the strategic frame question." The tactical fix earns time; it doesn't replace the strategic decision.
Summary
A tactical, no-regret fix for the
NodeFiltersilent-drop bug class from issue #117, designed to be shippable in one small PR without committing to the broader strict/permissive/hybrid frame decision.The principle: accept multiple equivalent input forms (lossless) — reject inputs we'd silently discard (lossy).
This frames the bright line between "convenience for the agent" and "bug class": lossless transformations of input are fine; lossy silent-drops are not. The frame question of #117 (strict vs. permissive vs. hybrid) is deferred — this issue only commits to "stop silently dropping things."
Why this exists
Issue #117 surfaces a real frame question with real tradeoffs, and the user explicitly asked for thinking-time before locking. But the production-blocking bug class (silent-drop of inapplicable filter fields) doesn't have to wait for the frame decision. It can be fixed cheaply, today, without committing to a frame.
This issue is the "ship the safety net first" path. If the eventual frame decision in #117 is strict: this work is the first migration step. If the eventual decision is permissive: this work is a no-cost compatibility safety net. If the eventual decision is hybrid: this work is half of step 1.
Lossless-permissive doesn't pick a side. It just stops the bleeding.
The principle, concretely
filterpassed as JSON-encoded string_coerce_filterfilterpassed as dict orNodeFilterfilter(e.g."kind": "client"inside filter)extra="ignore"fqn_prefix) sent withkind="client"That's the whole change. No new fields, no aliases, no smart behaviors. Just: the contract about what gets honored vs. discarded becomes explicit instead of implicit.
Why this is genuinely option-preserving
After this lands, the system is in a state where:
extra="forbid"later, add field aliases per kind, add smart behaviors per requestNone of those future doors close. The only door that opens is "no more silent-drop bugs of the #117 shape." That's the safest possible move because it eliminates the empirically-bad behavior without committing to a long-term frame.
Proposed implementation
Roughly one PR, ~50 lines of code, ~5 tests:
NodeFiltergetsmodel_config = ConfigDict(extra="forbid"). Unknown keys →ValidationError. Catches the"kind"-inside-filter pathology.Per-kind applicability check in
find_v2/search_v2/describe_v2/neighbors_v2. Before the Cypher push-down, validate that the populated fields onnfare a subset of the fields applicable tokind. Non-applicable fields →FindOutput(success=False, message=…)with a structured error.Error messages designed as teaching surfaces, e.g.:
The error message becomes a roadmap. Combined with PR propose: hints field as machine-readable road signs on MCP V2 outputs #120's hint field, the agent learns the contract from its own mistakes.
_coerce_filterstays untouched. JSON-decoding is lossless multi-form input — same input, different form. Not a permissive-frame thing; just a serialization convenience.No changes to
search.querypermissiveness. The fuzzy query → ranked score behavior is unaffected. This issue is about filter contract honesty only.Test coverage
ValidationErrorkind="client"→success=Falsewith structured messagekind="symbol"→ honored (no change)kind="symbol"→success=FalseWhat this does NOT do
resolveor any new tool. Would be premature; depends on frame.search.querysemantics. Query stays fuzzy text.member_fqn_prefixfor clients, no smarttarget_serviceresolution, etc.). Smart behaviors stay deferred.EdgeTypeliteral, kind literal, or any other shipped strict invariant.The caveat (deliberately surfaced)
Lossless-permissive is a tactical fix, not a strategic frame. It answers "stop silently dropping things" but it does not answer "what's the contract per kind?" The deferred question still has to be answered eventually, and the longer we wait, the more agents have been trained on the loud-fail-but-still-permissive surface, which biases future frame choices.
Concrete risk: if we ship lossless-permissive and then 3 months later try to add per-kind field aliases (toward permissive frame) or per-kind closed enums (toward strict frame), we'll have agent prompts in the wild that assume the current surface. They'll need updating. The longer we wait, the more agents need updating.
So the safest path isn't "ship lossless-permissive and forget about it" — it's "ship lossless-permissive AND keep #117 open as the strategic frame question." The tactical fix earns time; it doesn't replace the strategic decision.
Relationship to other issues
label(e) IN $listpredicate #119 — Kuzulabel(e) IN $listbug. Independent; should land first.hintsoutputs under propose: hints field as machine-readable road signs on MCP V2 outputs #120's road-sign discipline.Decision point for the maintainer
If you ship lossless-permissive:
extra="forbid", per-kind validation) is independently testable and shippableIf you wait for #117 to lock first:
Suggested sequencing (if you go with this)
label(e) IN $listpredicate #119 (Kuzu bug) — independent, one PRThat sequence is fully reversible at every step until step 5. Each step is small. None of them block another.
References
mcp_v2.py:49–66—NodeFilter(needsextra="forbid"config)mcp_v2.py:79–98—_coerce_filter(lossless multi-form input — stays untouched)mcp_v2.py:321–368—_node_matches_filter(per-kind post-filter; gets per-kind applicability check)mcp_v2.py:401–466—find_v2/search_v2(the entry points where per-kind validation lives)