Skip to content

lossless-permissive frame: tactical no-regret fix for #117 silent-drop bug class #122

@HumanBean17

Description

@HumanBean17

Summary

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.

Why this is genuinely option-preserving

After this lands, the system is in a state where:

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.

Proposed implementation

Roughly one PR, ~50 lines of code, ~5 tests:

  1. NodeFilter gets model_config = ConfigDict(extra="forbid"). Unknown keys → ValidationError. Catches the "kind"-inside-filter pathology.

  2. 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.

  3. 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'].
    

    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.

  4. _coerce_filter stays untouched. JSON-decoding is lossless multi-form input — same input, different form. Not a permissive-frame thing; just a serialization convenience.

  5. 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)

What this does NOT do

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

Decision point for the maintainer

If you ship lossless-permissive:

If you wait for #117 to lock first:

Suggested sequencing (if you go with this)

  1. Fix neighbors returns wrong-labeled edges: Kuzu 0.11.3 silently drops label(e) IN $list predicate #119 (Kuzu bug) — independent, one PR
  2. Ship lossless-permissive (this issue) — one PR, ~50 lines
  3. Ship propose: hints field as machine-readable road signs on MCP V2 outputs #120 hints field — one PR, already drafted
  4. Let 1-3 months of agent runs accumulate. Watch what error messages agents hit repeatedly.
  5. Revisit find filter contract: silent-drop bug surfaces the larger 'what is the filter contract per kind?' question #117 frame with that evidence. Decide strict / permissive / hybrid then.

That sequence is fully reversible at every step until step 5. Each step is small. None of them block another.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions