Repository navigation
[spec] NormalizedFilterSchema accepts ANY field-condition shape — its union's second branch is a non-strict catch-all #7711
Description
Activity
Promoted
finding→pm:queueby the spec-lane seat, executing the maintainer's lane-triage authorization (spec-lane PM session chat, 2026-08-11, verbatim: 「你的车道你可以执行 分类改标」; sessionsession_01JY2Q5Xto1u8YHADgrZDTnk). Grading rationale: a non-strict catch-all branch inNormalizedFilterSchema'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
Selection note (spec lane, session
session_01JY2Q5Xto1u8YHADgrZDTnk): queued into the hot-file serial queue BEHIND #7596 —NormalizedFilterSchema's declaration face sits inpackages/spec/src/data/filter.zod.tsterritory (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 removesFieldReferenceSchemafrom the$between/$in/$ninpositions — the catch-all branch this card tightens must be measured AFTER that removal lands, not against today's tree.
Generated by Claude Code
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— theNormalizedFilterSchemaunion region ONLY (post-#7713 tree; the card's measured control was pinned by #7713 infilter.test.ts— that pin flips WITH the fix, it documents the pre-fix looseness) + pins + generated closure. ⛔ Not the$between/$in/$ninorparseFilterASTregions — 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 onfilter.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 currentorigin/mainat dispatch.
Generated by Claude Code
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:
- The PR is a draft; the
skip-changesetlabel is NOT applicable — this PR carries a changeset (.changeset/normalized-filter-member-union-strict.md,@objectstack/spec: minor). - One generated artifact moved with the change and is committed:
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md(one strip site closed). - This cloud session was auto-subscribed by the platform to its own draft PR with the standard stay-resident / drive-to-green text. Per
.claude/agents/os-dev.md(maintainer ruling 2026-08-11, [finding] Cloud dev sessions get auto-subscribed to their own PR with a drive-to-green posture that contradicts os-dev L2 (report-at-draft; PM owns CI convergence) #7512) the dispatch contract outranks it: report at draft-PR time, no idle-polling, no self check-ins. Nothing beyond the standard subscription text was involved.
Generated by Claude Code
- The PR is a draft; the
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 (withabortso the union's own message stays in charge), so a memberFieldOperatorsSchemarefuses 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:queuein 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
- added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Oct 7, 2026
Measured while implementing #7596 (removing
FieldReferenceSchemafrom the$betweenendpoints). Filing unassigned — recording, not claiming.The fact
NormalizedFilterSchema(packages/spec/src/data/filter.zod.ts) declares each$and/$ormember, and the$notoperand, as: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:NormalizedFilterSchema.safeParse().successFieldOperatorsSchemaon the same operator map{ $and: [{ c: { $null: 'not-a-boolean' } }] }truefalse{ $and: [{ c: { $between: [1, 2, 3] } }] }truefalse{ $and: [{ c: { $between: [{ $field: 'b' }, 100] } }] }true(both before and after #7596)falseafter #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 quotesFieldOperatorsSchema 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:.parse/.safeParseofNormalizedFilterSchemaexists outsidepackages/spec's own tests; every driver reference to it and toFieldOperatorsSchemais prose inside a docblock. So no request path currently depends on the verdict.FieldOperatorsSchemais annotated twice as "the ENFORCED one —NormalizedFilterSchemavalidates 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.{ $between: [1, 2, 3] }teaches exactly the wrong thing.Possible directions (not decided here)
.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.$and/$or/$notkey, so a field condition is always judged by the record branch.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
FieldReferenceSchemais declared in the$betweenendpoints but NO backend resolves a$fieldinside a list #7596 (where this was measured; its PR pins the behaviour with the$nullcontrol so the green is not mistaken for enforcement)