Skip to content

[spec] NormalizedFilterSchema accepts ANY field-condition shape — its union's second branch is a non-strict catch-all #7711

Description

@os-zhuang

Measured while implementing #7596 (removing FieldReferenceSchema from the $between endpoints). Filing unassigned — recording, not claiming.

The fact

NormalizedFilterSchema (packages/spec/src/data/filter.zod.ts) declares each $and / $or member, and the $not operand, as:

z.union([
  // Field condition: { field: { $op: value } }
  z.record(z.string(), FieldOperatorsSchema),
  // Nested logical group
  NormalizedFilterSchema,
])

The second branch is z.object({ $and, $or, $not }) with every key optional and no .strict(). So when the record branch rejects a field condition, the object branch accepts the very same value — any object whatsoever satisfies "all three of my optional keys are absent".

The whole-filter face therefore validates the LOGICAL skeleton and nothing else. No comparand shape it declares can ever make it fail.

Measured

Run against origin/main @ 6a9dec6, and again on the #7596 branch — same answers on both, so this is not #7596's doing:

input NormalizedFilterSchema.safeParse().success FieldOperatorsSchema on the same operator map
{ $and: [{ c: { $null: 'not-a-boolean' } }] } true false
{ $and: [{ c: { $between: [1, 2, 3] } }] } true false
{ $and: [{ c: { $between: [{ $field: 'b' }, 100] } }] } true (both before and after #7596) false after #7596

$null: 'not-a-boolean' is the clean control: it has never been a declared comparand, and three drivers carry hand-written refusals whose message quotes FieldOperatorsSchema declares $null as a boolean — a declaration the schema face itself does not enforce at this level.

Why it matters

It is a declaration-face gap rather than a live defect today, which is why this is filed as a finding:

  • Nothing calls it. No .parse / .safeParse of NormalizedFilterSchema exists outside packages/spec's own tests; every driver reference to it and to FieldOperatorsSchema is prose inside a docblock. So no request path currently depends on the verdict.
  • The comments say otherwise. FieldOperatorsSchema is annotated twice as "the ENFORCED one — NormalizedFilterSchema validates against it". It does, on one branch of a union whose other branch accepts everything, which makes the sentence true in letter and misleading in effect. A future consumer wiring the whole-filter face in as a validation step would get a green on comparands no backend accepts.
  • The AI-authoring axis. ADR-0033's population reads the declared surface to decide what is writable. A face that answers "valid" to { $between: [1, 2, 3] } teaches exactly the wrong thing.

Possible directions (not decided here)

  1. .strict() on the recursive object branch, so a field-condition object cannot slip past as an empty logical group. Cheapest; needs a check for filters that legitimately mix logical keys and field keys at one level.
  2. Reorder / discriminate the union on the presence of a $and / $or / $not key, so a field condition is always judged by the record branch.
  3. Leave it and delete the "ENFORCED" claim from the two comments, on the grounds that the whole-filter face is a shape declaration and the per-operator face is the enforcement point.

Option 1 or 2 is what "declared = enforced" (ADR-0049) points at; option 3 is honest but gives up a face that reads like it validates.

Refs

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Promoted finding → pm:queue by the spec-lane seat, executing the maintainer's lane-triage authorization (spec-lane PM session chat, 2026-08-11, verbatim: 「你的车道你可以执行 分类改标」; session session_01JY2Q5Xto1u8YHADgrZDTnk). Grading rationale: a non-strict catch-all branch in NormalizedFilterSchema's union is an acceptance-face hole of the strictness family — declared shape ≠ enforced shape, the class the lane queues without further debate. Premise to re-verify at dispatch per standard discipline.


    Generated by Claude Code

  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Selection note (spec lane, session session_01JY2Q5Xto1u8YHADgrZDTnk): queued into the hot-file serial queue BEHIND #7596 — NormalizedFilterSchema's declaration face sits in packages/spec/src/data/filter.zod.ts territory (api-surface: data), the same file #7596's in-flight removal edits. Dispatches when #7596's PR merges; anchors re-verified at that ref then. Trap recorded: #7596 removes FieldReferenceSchema from the $between/$in/$nin positions — the catch-all branch this card tightens must be measured AFTER that removal lands, not against today's tree.


    Generated by Claude Code

  3. self-assigned this
    on Aug 11, 2026
  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 3 (spec lane — hot-file queue head cleared: #7596's PR #7713 MERGED ~13:40Z)
    Session: session_01JY2Q5Xto1u8YHADgrZDTnk
    Branch: claude/issue-7711-normalized-filter-strict-catchall
    Worktree: cloud dev session (own container)
    Domain: domain:spec
    File surface: packages/spec/src/data/filter.zod.ts — the NormalizedFilterSchema union region ONLY (post-#7713 tree; the card's measured control was pinned by #7713 in filter.test.ts — that pin flips WITH the fix, it documents the pre-fix looseness) + pins + generated closure. ⛔ Not the $between/$in/$nin or parseFilterAST regions — both settled today (#7713, #7672). Stop on breach; explain in the report.
    Container & model: M (acceptance-face tightening of the strictness family with a blast-radius measurement), mode:cloud, model: opus
    Serial constraints cleared: predecessor #7713 (same file) MERGED; no other in-flight writer on filter.zod.ts (open PRs + queue checked 13:52Z). Same-day churn is HEAVY on this file (#7593, #7672, #7713 all merged today) — every anchor from the card re-verified against current origin/main at dispatch.


    Generated by Claude Code

  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    OS-DEV-REPORT — #7711

    {
      "issue": 7711,
      "status": "done",
      "branch": "claude/issue-7711-normalized-filter-strict-catchall",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7772",
      "premise_still_valid": true,
      "summary": "Premise re-verified on origin/main @ 8669e5d (post-#7713): the member union's second branch was a non-strict z.object({ $and, $or, $not }) with every key optional, so it accepted any object at all — every field condition the record branch rejected landed there. Fixed with the issue's options 1 AND 2 together: the group branch is now .strict(), and the field-condition branch rules out $-prefixed keys (option 1 alone leaves a leak — FieldOperatorsSchema is not .strict(), so `{ $not: <bad subtree> }` parsed as a FIELD named `$not` with its operand stripped to {}, never reaching the group branch). Refusal is message-bearing on the union's own `error` callback, naming the offending keys and both valid member shapes, with abort:true on the field-branch refinement so zod 4.4.3 cannot promote it to the union's spokesman. Measurement the card had not made and that decided the fix over option 3: the pre-fix green was also LOSSY — an admitted member came back parsed to {}, so the accepted output no longer carried the condition it was asked about. That also makes the one shape strictness might have cost — a group key + field key at one level — a non-loss: it already parsed to `{ $or: [] }` with the field condition dropped. #5322's empty-combinator identities ({}, { $and: [] }, { $or: [] }, { $or: [{}] }, { $not: {} }) all stay accepted and are pinned. Blast radius measured at ZERO: no .parse/.safeParse caller of NormalizedFilterSchema outside packages/spec's own tests (positive control: the same grep over FilterConditionSchema returns 20+ call sites across spec, service-analytics and metadata-protocol). Cross-path check requested by the card: drivers and evaluator consume the LOWERED FilterCondition — driver-memory 16 / driver-sql 21 / driver-mongodb 3 / driver-sqlite-wasm 3 / driver-turso 6 / formula 12 / objectql 9 files reference FilterCondition and ZERO reference NormalizedFilter — so this schema-door tightening cannot diverge any row set. service-analytics' NormalizedFilterNode is an unrelated local type. Exported NormalizedFilter type is byte-identical. Changeset graded minor (acceptance-surface narrowing, following action-param-strict-unknown-keys and action-strict-envelope-zero, both minor); NOT the #7657/ADR-0087 major grade, because nothing is removed from the declared surface and the shapes that stop parsing are produced by nothing. Region boundary respected: only the NormalizedFilterSchema union region; $between/$in/$nin and parseFilterAST untouched. The two 'this copy is the ENFORCED one' comments are left as-is — they sit in the settled $between neighbourhood and are now simply true.",
      "tests": "FLIP-DON'T-DELETE: #7713's pin 'the whole-filter face is loose about field conditions — pre-existing, control included' flipped in place (same two inputs, same two unchanged FieldOperatorsSchema assertions; only the whole-filter verdict moved true->false), plus a new 8-case #7711 describe block asserting issue code + path + message spans, the #5322 identities staying green, and output preservation. || pnpm exec vitest run src/data/filter (9 files): 'Test Files 9 passed (9) / Tests 309 passed (309)'. || Full @objectstack/spec suite: 'Test Files 377 passed | 1 failed (378)' on the first run — the failure was scripts/strictness-ledger-doc.test.ts, the generated counts doc lagging my new strict site; regenerated with gen:strictness-ledger (filter.zod.ts strip 11->10, repo strict 251->252, strip 181->180) and it is green: 'Test Files 1 passed (1) / Tests 20 passed (20)'. Whole-suite figure at that point: 9955 tests. || typecheck (tsc --noEmit + check:scripts-typecheck + check:test-typecheck): green, 'test layer compiles under packages/spec/tsconfig.test.json'. || REVERSE VERIFICATION, direction predicted BEFORE running as 'red — the usual' and taken out with `git checkout origin/main -- packages/spec/src/data/filter.zod.ts` against a checkpoint commit (never git stash): 'Tests 8 failed | 128 passed (136)' — exactly the predicted set, the 7 new pins plus the flipped #7713 control, while 'leaves the #5322 empty-combinator identities accepted' stayed GREEN, which is the case that must not depend on the fix. Restored: 136/136 green. || Gates: check:driver-conformance OK (38 covered cells, 2 DEBT, 0 exempt); check:merge-driver self-tests pass; check:adr-anchors OK (22384 citations across 3690 files resolve, exit 0); check:spec-parsed-alias OK; check:nul-bytes OK (7128 files, no raw control bytes); spec check:generated 'All 13 generated artifacts are up to date' after building spec; check:authorable-surface green with authorable-surface.base.json unchanged by the rebuild. || Consumer sweep DIRECTION, stated so it can be reviewed: NOT the full prefix sweep (--filter '...@objectstack/spec' is effectively the whole repo). Spec's own typecheck plus one targeted downstream consumer, @objectstack/objectql typecheck green with its dep closure built first via the '^...' SUFFIX filter — justified by the exported type being byte-identical and the runtime narrowing having no caller to sweep for. || CI: not read — reporting at draft-PR time per the dispatch contract, no idle-polling; the PM owns CI convergence.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Notes for the PM, outside the JSON:


    Generated by Claude Code

  6. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Close-confirm — PR #7772 merged via the merge queue. NormalizedFilterSchema's union catch-all is closed: the logical-group branch is .strict() and the field-condition branch refuses $-prefixed keys (with abort so the union's own message stays in charge), so a member FieldOperatorsSchema refuses has nowhere to land. The #5322 empty-combinator identities stay accepted, the exported type is unchanged, and the previously-lossy parse (admitted members erased to {}) now round-trips.

    Provenance: promoted from finding to pm:queue in the seat-#6017 full-lane triage sweep, accepted by the maintainer 2026-08-11 (「接受你的建议,开始加速处理。」); dispatched with the post-#7713 hot-file sequencing, ACCEPT-reviewed at step 7. Merged after the 14:30–16:23Z queue wedge (#7802/#7818) that had ejected it once — re-queued post-fix, second build green.

    Spec-lane PM, session session_01JY2Q5Xto1u8YHADgrZDTnk.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions