Skip to content

fix(objectql)!: having takes the rest of where's filter doors — the comparand-type door, row-independent refusals, a resolved { $field }, and a refused non-condition - #20117

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20099-having-where-doors
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20099-having-where-doors

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20099
Clause-②: no (narrowing)

This gives the having clause of engine.aggregate the rest of the filter doors where takes, at the entry PR #20097 added. It follows the dispatch's binding frame: ruling 乙 on #19757 (record 5793368540), the seat default 协议为基准, and the ADR-0087 entry filter-between-field-reference-endpoint-refused, whose prescription 「#5222 compiles on every face」 having now honours.

Session session_01Bvd69VPa6puiNzzPUroDBx, branch claude/issue-20099-having-where-doors. Base aa04ea2964, head 98abdf4ee3. Every reading below was taken on one of those two commits, as stated. Heads 01f091f523 and f08f8953fc correct changeset cells and change no code or test: engine.ts, having-filter.ts and the test file are byte-identical to 98abdf4ee3.

1. Measured first (H1)

Each shape was run through the public engine.aggregate on a real InMemoryDriver and a real SqliteWasmDriver. Each having ran on both applyHaving doors: the native driver.aggregate() door, and the fallback door forced by a per-aggregation filter. Each ran on a populated and on an empty grouped set. That is 8 cells per shape and version. Each shape's where twin, over the raw columns, was the control. Groups: c1 (total 500, max_cap 50), c2 (total 900, max_cap 5000), c3 (total 20, max_cap 20).

H1 holds: all four rows still held at base aa04ea2964. In every row, all 8 cells answered alike on both drivers and both doors, except where the table below says otherwise.

row shape (as a having) where twin having at base having at head
1 { total: { $gt: { $field: 'max_cap' } } } sqlite: resolved rows; memory: none no group c1
1 $gte / $lt / $lte / $eq against { $field: 'max_cap' } sqlite: resolved no group c1,c3 / c2 / c2,c3 / c3
1 $ne against { $field: 'max_cap' } sqlite: resolved every group c1, c2
1 the two-bound spelling { total: { $gte: { $field: 'max_cap' }, $lte: 1000 } } sqlite: resolved no group c1, c3
1 a bare { total: { $field: 'max_cap' } } 400, both drivers 400 populated, 200 [] empty 400 on every cell, the operator written in
1 a reference as an $in / $nin member, a $contains / $notContains pattern, an $exists / $null operand sqlite: 400 no group, or every group 400 on every cell
1 a reference naming no column, e.g. { $field: 'nope' } sqlite: 400 per operator (the reference object itself was compared): no group under $eq, every group under $ne; under an ordering operator, none against a number column, and against a text or date column an answer that follows each value's string order against the text [object Object] 400 on every cell, listing the columns
2 { total: { $eq: { v: 1 } } }, { total: undefined }, $eq: new Map(), a function, an undefined $in member, a bigint beyond 2^53, { $field: 5 } 400, the comparand-type door per operator, on the numeric total: no group in the implicit slot (the non-object values) and under $eq / $gt / $gte / $in; every group under $ne / $nin; under $lt / $lte no group, except the bigint, which kept every group 400, the same door's words rooted at having
2 { total: { $in: [500n, 20n] } } narrowed, the rows no group c1, c3
3 [['total', '>', 100]], ['total', '>', 100], ['and', …] lowered, the rows no group 400 on every cell
3 [], 'total > 100', 100, a Map []: no filter; scalars: every row (see Acceptance notes) every group 400 on every cell
4 { total: { $median: 1 } }, $nand, $regex, an empty or non-string $icontains 400 400 populated, 200 [] empty 400 on every cell, the walker's own words
4 { nope: { $median: 1 } } (a column the row lacks) 400 200 [] on both 400 on every cell
4 { $or: [{ total: { $gt: 0 } }, { total: { $median: 1 } }] } 400 every group on a populated set, [] on an empty one 400 on every cell

54 shapes were measured, 432 having cells per version. At base, 28 refusal cells were the walker's, raised after the driver had been asked for rows (aggregate 1 / find 0 or 0 / 1), and 8 were PR #20097's face. At head, all 272 having refusal cells are raised before any driver call: aggregate 0 / find 0.

The controls gave the same bytes at base and head, on every cell: a scalar $gt, an implicit scalar, a key naming no column, an $in list, $ne: null, a plain-object implicit value, {}, a $ne reference on a missing column, a Date bound and the bigint 500n. The re-measure of base after the change matched the first base measurement byte for byte on all 46 original shapes.

2. Where where gets each door (H2)

Located by symbol, in ObjectQL.aggregate → lowerWhereFilterArray (engine.ts). The same function serves find and count.

  • FilterArray lowering. The array branch calls isFilterAST → parseFilterAST(where, context) from @objectstack/spec/data. parseFilterAST runs the shape face and the type door internally with the path fixed at where: it has no root argument. So it cannot lower a having without printing where. in having's refusals, and packages/spec is not changed here. More decisive: the spec does not declare the sugar on this slot (§3).
  • The comparand-type normaliser. The object branch calls normalizeFilterComparandTypes(where, context). The function takes a path argument, so it runs on query.having as it stands, rooted at having, with no spec change. It needs no column set: it judges comparand types, not names.
  • Structural validation. Four engine doors run on where: assertListComparandShapes, already on having since PR fix(objectql)!: route having through the shared comparand-shape face — having: { total: [5] } is refused like the same shape in where #20097; assertFilterIsMaterializable, assertTextOperatorTargetsAreStringCapable and assertTemporalComparandsInterpretable. The last three judge the OBJECT's declared fields, a namespace having does not filter. Unknown-operator refusals for where are raised by the drivers. having never reaches a driver, so its structural check is its own walker's, run once against the aggregated row's column set. That set is read off the query: the groupBy projections, a structured item's alias or field, and every aggregation alias.

3. What changed

  • packages/objectql/src/engine.ts, ObjectQL.aggregate, the having entry only, beside the shape-face call:
    1. assertHavingIsFilterCondition(query.having). having is null, absent or a plain object; anything else is refused. QuerySchema.having and EngineAggregateOptions.having both declare FilterConditionSchema. Measured: both schemas refuse every array, [] included. The FilterArray sugar is declared on the where slot alone (TransportFilterValueSchema, 「where widens to the FilterArray sugar here and ONLY here」). The wire door already refuses a having array (protocol.query-param-arity.test.ts).
    2. The existing assertListComparandShapes(…, 'having').
    3. normalizeFilterComparandTypes(query.having, "aggregate('order')", 'having'). A narrowed bigint replaces the clause copy-on-write, and the caller's object is not edited.
    4. assertHavingIsEvaluable(having, aggregatedRowColumns(query.groupBy, query.aggregations)).
  • packages/objectql/src/having-filter.ts:
    • assertHavingIsEvaluable walks the whole clause once, the $and / $or / $not walk matchesHaving takes. It raises the walker's own refusals through the same constructors (unknownOperator, icontainsComparandError), in the order the per-row walk would meet them. It adds four { $field } refusals: a bare reference, a reference outside the six scalar comparisons, a reference its own FieldReferenceSchema refuses (a malformed addDays), and a reference naming no column. The per-row throws stay as the floor for a caller that evaluates rows directly.
    • checkCondition resolves a { $field } reference that is the whole comparand of $eq / $ne / $gt / $gte / $lt / $lte against the row. The comparison is @objectstack/formula's matchesFilterCondition, the in-memory evaluator the SQL cross-field compiler is held to row for row, handed a three-column probe so a flat column name is never read as a dotted path. It supplies the NULL totality and the whole-day addDays arithmetic, which are not copied here.
  • The test file engine-aggregate-having-comparand-shape.test.ts extends PR fix(objectql)!: route having through the shared comparand-shape face — having: { total: [5] } is refused like the same shape in where #20097's where/having parity table (37 → 112 tests), both doors, with an empty-grouped-set leg on every refusal.
  • .changeset/20099-having-where-doors.md.

4. $field against the aggregated row (H3)

Resolved, not refused. Resolution honours the declared form, FieldReferenceSchema, and the two-bound spelling the $between refusal prescribes on having. It needs no data at judgement time: position and name are checked against the query's own column set before any row exists. A reference that cannot resolve is refused on an empty set exactly as on a populated one. A reference that can resolve answers [] on an empty set and rows on a populated one, like any filter. The resolution runs the same function on both doors, and applyHaving is the only evaluator on either.

5. Declaration (H4)

The accept set only narrows. Every shape accepted at head was accepted at base, and no refusal at base is lifted. Row 2, row 3, row 4 and the four { $field } refusals are narrowings. Rows 1 (resolution) and 2 (bigint narrowing) change answers of inputs that were already accepted: no group, or every group under $ne, becomes the rows the filter names. That is a correction of answers, not a widening of what is accepted, so Clause-②: no (narrowing) stands.

  • check-changeset-no-major --base aa04ea2964: ✓ This diff introduces no major bump. Its clause-② level axis reads NOT APPLICABLE locally (no pull_request payload); CI reads it from this body.
  • check-adr-0087-registration --base aa04ea2964: ✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. · .changeset/20099-having-where-doors.md [BREAKING+bang+clause-②-narrowing] not-required (already-registered).
  • The ids are filter-between-field-reference-endpoint-refused (the prescription having now honours, and the list-member position it refuses), filter-icontains-comparand-refused-at-parse and filter-regex-options-retired. The marker's prose names the transitions no entry covers, and why none is needed: having is a request-only key, and no stored document exists for objectstack migrate meta to rewrite.

6. Tests, reverse verification, ablation, gates

Head 98abdf4ee3 unless stated. It differs from ce22319645, where the typecheck ran, by the changeset only.

  • @objectstack/objectql vitest run --project local: 314 files, 5417 passed. --project repo: 1 file, 5 passed.

  • @objectstack/objectql typecheck, at ce22319645: exit 0. check:test-typecheck reads OK, 40 files / 234 errors held, unchanged.

  • The extended parity file: 112 passed.

  • Consumer census. engine.aggregate takes having from ONE non-test source caller, metadata-protocol protocol.ts (the REST aggregate branch). Its suites were run with objectql's dist rebuilt (turbo, 24 tasks, 18 cached; dist carries assertHavingIsEvaluable, 2 hits):

  • Reverse verification. engine.ts and having-filter.ts were put back to their BASE blobs (a9ec130693, 2514be70bb) with git restore --source, under an EXIT/INT/TERM trap.

  • Ablation, one per new door, through scripts/ablation-replace.mjs. In each, the anchor hit once, x1 → x0, the blob moved, and the file was restored to its HEAD blob with git diff HEAD empty. The tests import ./engine.js from source, so no dist/ is on the resolution path.

    ablated failed / 112 red set
    assertHavingIsFilterCondition(query.having) 9 exactly the 9 not-a-condition rows
    the type-door call (clause passed through as is) 21 the 10 type-door parity rows, the 10 FILTER_COMPARAND_TYPE_CASES type rows, and the bigint narrowing
    assertHavingIsEvaluable(…) 25 the 10 walker rows, the 14 reference refusals, and the column-list row
    the resolution branch in checkCondition 14 the 12 resolution rows, the NULL-semantics row, and the per-aggregation-filter row
  • Gates. dispatch-gates --commands --repo objectstack-ai/objectstack was re-derived at head: 64 families. Each was run and its exit code recorded, then reconciled with --ran: ✓ 64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).

    • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, both exit 3 PREREQUISITE NOT MET. They need the whole workspace built, and CI runs them.
    • Selected lines: query-options-erasure ratchet holds: 67 unswept non-test site(s); check-nul-bytes: OK (scanned 9536 text file(s)…); check-engine-double-contract: OK; where-matcher conformance holds; ObjectQL double limit conformance holds; doc authoring guard: 403 files clean; check-driver-memory-census: OK.
    • node scripts/check-issue-citations.mjs --base aa04ea2964: ✅ every citation this change adds resolves (19 judged).
  • Lint, a declared narrowing (pnpm lint is CI's). eslint --no-inline-config --format json on the 3 changed .ts files:

    • count: 3 files, 0 errors, 0 warnings;
    • population: --print-config returns a config for each file;
    • invariance: eslint.config.mjs sets no parserOptions.project and no typed rule, so no untouched file's verdict can move.

7. Compile surfaces, face by face

face conclusion
1 driver-sql (with driver-sqlite-wasm, local driver-turso) not reached by having: no driver reads the clause. Unchanged
2 turso RemoteTransport not reached. Unchanged
3 read-scope-sql not reached. Unchanged
4 analytics filter-normalizer not reached; analytics sends no having to the engine, and the analytics having is outside this claim. Unchanged
5 formula consumed, not changed: matchesFilterCondition now also evaluates a having reference comparison. Its code is untouched
half-face objectql having-filter changed: behind the comparand-type door, the condition-object check and the row-independent walker, on both applyHaving doors. { $field } is resolved in the six scalar comparisons. The where/having parity table now covers the type door and the new arms
driver-memory / driver-mongodb not reached by having. Unchanged

The author's text is the wire text. No source writes the clause. The two greps, over non-test package sources:

  • git grep -nE "\.having\s*=[^=]" finds no hit.
  • git grep -n having -- packages/plugins/plugin-security/src finds one reader, predicate-guard.ts:70, collectConditionFields(ast.having, out), which walks field names and writes nothing.

Every refusal added here runs before executeWithMiddleware in any case. Every refusal the tests pin asserts code and status.

8. Deviations from the claim, declared

  • FilterArray on having is REFUSED, not lowered. The claim's surface says "FilterArray lowering", and H4 expected "lowered sugar". The declared contract answered otherwise. having is FilterConditionSchema on both QuerySchema and EngineAggregateOptions, and both refuse an array. The sugar is declared on where alone, and the protocol door already refuses a having array. Lowering it would widen having's contract, a protocol change the frame rules out. It would also print where. in having's refusals, because parseFilterAST has no root argument. The report carries this as an open question.
  • The per-aggregation filter resolves { $field } too. It shares checkCondition with having (its module note: 「a predicate moved between a driver where, a having, and a per-aggregation filter must select rows by one rule」), and forking the walker by clause would give one walker two rules. The bounded in-place exemption applies: the same defect class, the same code path, no other claim on the file, and the same gates. Measured on both real drivers, count with filter: { amount: { $gt: { $field: 'cap' } } } went from c1 0, c2 0, c3 0 at base to c1 2, c2 1, c3 0 at head. A test pins it. Its entry-level refusals are NOT added: that loop is outside the claimed having entry. See Acceptance notes.

Acceptance notes

Observed and not fixed here. The report carries each one with its class and evidence.

  • A per-aggregation filter's walker refusals still depend on the data. aggregations: [{ …, filter: { amount: { $median: 1 } } }] is INVALID_FILTER / 400 on a populated table and 200 [] on an empty one, on both drivers. This is row 4's class, at the sibling position; the fix is this PR's assertHavingIsEvaluable walk run per aggregation filter, in the engine loop outside this claim.
  • An engine where that is a string, a number or a Map is dropped. engine.find('order', { where: 'amount > 100' }) returns every row on both drivers.
  • The engine's refusal of a non-filter where array carries no envelope. where: [1, 2, 3] throws with code and status undefined, on both drivers.
  • driver-memory does not resolve a where { $field } reference. { amount: { $gt: { $field: 'cap' } } } answers [], while driver-sqlite-wasm answers the resolved rows.
  • A having key naming no column keeps no group, silently ({ totl: { $gt: 100 } }). The engine knows the column set, so the same check as the reference's could refuse it; it is not one of this card's rows.
  • addDays against a numeric aggregate follows the in-memory evaluator, which reads a number as epoch milliseconds: { total: { $gt: { $field: 'max_cap', addDays: 1 } } } keeps no group. SQL push-down refuses that pair on where, and driver-memory does not resolve it at all. The aggregated row carries no declared type to judge the pair statically. The report records this as an open question.
  • $like / $ilike are still refused on having. That is the documented staging in FILTER_OPERATORS, carried by the follow-up on Filter AST: like is folded to $contains at the wire — wildcards bind as literals and driver-sql's like/ilike arm is unreachable #7536, and not a new gap.
  • The having-filter.ts header still calls HAVING 「the only face no conformance table covers」. That remains true of the logic axis (FILTER_LOGIC_CASES). The shape and type axes are now covered by the parity table. The header is not edited, to keep the claim.

Generated by Claude Code

…or, row-independent refusals, resolved $field, array/scalar refused

WIP: implementation; tests and changeset follow.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
…, the condition-object check, row-independent walker refusals and $field resolution

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

16 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json bc80e16260597d52b062b7e1ee810c7b9a99aed2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3935246c5113efaa9df83b31bf1c6ff31dc43a5f — the merge of head f08f8953fca34b5ae8f59eebfeab5fea52072862 into base bc80e16260597d52b062b7e1ee810c7b9a99aed2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3935246c5113efaa9df83b31bf1c6ff31dc43a5f && git checkout 3935246c5113efaa9df83b31bf1c6ff31dc43a5f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bc80e16260597d52b062b7e1ee810c7b9a99aed2 f08f8953fca34b5ae8f59eebfeab5fea52072862 && git checkout -B drift-repro bc80e16260597d52b062b7e1ee810c7b9a99aed2 && git merge --no-ff f08f8953fca34b5ae8f59eebfeab5fea52072862

node scripts/docs-audit/affected-docs.mjs --json bc80e16260597d52b062b7e1ee810c7b9a99aed2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 98abdf4ee3606a9c52517a7a3951a12b3206b72b

Scope: 4 files (+755/−14) on merge base aa04ea2964:

  • packages/objectql/src/engine.ts (+35/−1), the having entry of ObjectQL.aggregate only;
  • packages/objectql/src/having-filter.ts (+352/−1);
  • the parity test file (37 → 112 tests);
  • the changeset.

packages/spec is untouched, and no governed surface is touched.

Read: card #20099 and its comments, #19974, PR #20097 and its records 5826694842 / 5826950623, ruling 乙 on #19757 (5793368540), the PR body, the diff, all 34 check-runs, AGENTS.md, contract-review.md, review-checklist.md and landing-operations.md.

Measured in detached worktrees of head and base through the public engine.aggregate, on a real InMemoryDriver and a real SqliteWasmDriver, on both applyHaving doors, with populated and empty grouped sets (plus a group with a NULL column): 70 shapes × 8 cells per tree. Each shape's where twin was the control. Driver calls were counted.

① Derived judgments

  • The four card rows: all four reproduced at base and closed at head.
    • Row 1: { $field } in the six scalar comparisons now resolves against the aggregated row ([] → the right groups). A bare { $field }, an unknown column and a reference in a list, pattern or operand slot are refused 400.
    • Row 2: the comparand-type door's words, rooted at having.
    • Row 3: every FilterArray spelling and every non-condition having is refused 400.
    • Row 4: the walker's refusals no longer depend on the data.
    • One cell of the dev's rows table is mislabelled ($gt $field groupBy col). The measured answer fits the $eq spelling the test file pins, so there is no code consequence.
  • Row-independence: RIGHT. 344 of 344 head refusal cells fire with 0 driver calls, identically on the empty and the populated set, on both drivers and both doors. At base, 32 of 56 refusal cells raised only after a driver call. The entry order is: condition check → shape face → type door (having-rooted) → assertHavingIsEvaluable against aggregatedRowColumns. All of it runs before getDriver and the middleware.
  • $field resolution: RIGHT.
    • It works alias against alias and against a groupBy column.
    • An unknown or dotted column is refused, and the refusal names the column set.
    • $ne and the two-bound spelling are correct.
    • NULL handling matches the changeset's cross-field reading.
    • Resolution goes through @objectstack/formula's matchesFilterCondition, with no comparison copied.
  • The per-aggregation filter's { $field } resolution is a bounded in-place fix, not a scope breach. It shares checkCondition with having; the change is mechanical and pinned; no other claim holds having-filter.ts; and the gate family is the same. Its own entry-level doors were not added, as declared (objectql: a per-aggregation filter refuses an unknown operator only when rows exist — aggregations: [{ filter: { amount: { $median: 1 } } }] answers 400 on a populated table and 200 on an empty one #20122).
  • Nothing outside having moved: RIGHT.
  • The pins discriminate: RIGHT. With both files at their base blobs: 69 failed / 43 passed, the PR's exact claim. Each file was restored by blob.
  • Compile surfaces: declared correctly. Only the objectql having-filter half-face changed. formula is consumed, not changed.
  • CI at this head: 34 runs, 31 success, 3 skipped, 0 failures, and every required context is success.

② Semver level

@objectstack/objectql: minor, fix(objectql)!:, BREAKING, Clause-②: no (narrowing): CORRECT.

  • Across 70 shapes × 8 cells: 40 shapes narrowed, 11 changed answers, 0 widened.
  • Row 1 ([] → rows) is not an accept-set widening. Those inputs were accepted at base and at head, and only the answer moved to the one the declared contract gives (FieldReferenceSchema; ADR-0087 entry filter-between-field-reference-endpoint-refused).
  • ADR-0087 not-required (already-registered …): the three ids resolve, and having is request-only.

③ Boundary flags

  • Q1, FilterArray on having → A, refuse: this is what the spec says. QuerySchema.having, EngineAggregateOptionsSchema.having, FilterConditionSchema and QueryWithTransportSchema.having all refuse arrays. The sugar is declared on where only. Lowering would widen the spec.
  • Q2, addDays on a non-temporal referent → A is contract-consistent.
    • FieldReferenceSchema.addDays prescribes no in-memory refusal. The SQL refusal is keyed on a stored storage class, which an aggregated row does not declare.
    • where already answers this pair three ways by face.
    • A static class derivation is the principled end state and deserves a card of its own. It is not a must-change here.
  • Changeset: TRUE in every sentence except two cells of the FROM table, whose "what it did before" depends on the operator and is FALSE as written:
    • Row 1, "… a Map, a function, a Symbol, a bigint beyond 2^53, or { $field: 5 } as a comparand | kept no group": under $ne each of these kept EVERY group at base.
    • Row 7, "… an addDays that is not an integer or a { $field } | kept no group": under $lte, addDays: 1.5 and addDays: '7' kept EVERY group at base.
    • The PR body §1 row 2 cell repeats the first. The remedy columns are right.
  • PR body: TRUE where measured, including the acceptance notes. The per-door ablation table and the consumer-suite counts are UNMEASURED here.
  • Claim bookkeeping: the claim's file surface for having-filter.ts should name the per-aggregation filter through the shared walker.
  • Out-of-scope findings 1–5 are REAL, and all five were reproduced.

Implemented-by: claude/issue-20099-having-where-doors
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 01f091f5234c4152c61f749c6828bde64743dd73

Scope: the delta over PASS 5828166480 (98abdf4ee3): one commit, one file (.changeset/20099-having-where-doors.md, +2/−2), rewriting the "what it did before" cells of FROM-table rows 1 and 7, plus the PR-body edits.

  • git diff --stat 98abdf4ee3..01f091f523 -- packages/ is empty.
  • The ADR-0087 marker line is byte-identical.

Measured at base (engine.ts / having-filter.ts at their base blobs, then restored by blob) through the public engine.aggregate:

  • on a real InMemoryDriver and SqliteWasmDriver, on both doors (confirmed by call counts), on populated and empty sets;
  • with numeric, lowercase-text (customer_id: c1…), uppercase-text, digit-leading-text and date columns;
  • 282 shapes × 8 cells per tree. Every shape answered identically on both drivers and both doors.

① Derived judgments

  • Delta shape: RIGHT. No code, test, marker, semver or clause line moved.
  • Row 1: TRUE in every clause, over 8 comparands × 9 slots on the numeric total:
    • the implicit slot's non-object values, $eq, $gt and $gte kept no group;
    • $ne kept every group;
    • $lt / $lte kept no group, except 2n ** 60n, which kept every group;
    • a Symbol under an ordering operator threw a raw TypeError with no code and no status on a populated set (16/16 cells).
  • Row 7: FALSE in two clauses. Five references were measured × 7 columns × 6 operators.
    • $eq no group, $ne every group, and number columns keeping no group under the ordering operators: all TRUE.
    • Against a text or date column the object compares as the text [object Object], so the answer follows the string order of the values, not the column's type.
      • Values that sort before [ (uppercase, digit-leading and ISO dates) keep every group under $lt / $lte and none under $gt / $gte.
      • The lowercase customer_id (c1…, the fixture's own column) keeps every group under $gt / $gte and none under $lt / $lte.
    • So "no group under $gt and $gte" and "under $lt / $lte, every group against a text or date column" are each FALSE for that column.
    • The PR body's "reference naming no column" cell states the same two clauses, and is FALSE in the same way.
  • PR body §1, row 2: TRUE, except that "no group in the implicit slot" lacks the changeset's "(the non-object values)" qualifier. A bare { $field: 5 } there was refused at base, not answered.
  • Session line: TRUE.
  • CI at this head: 41 runs, 36 success, 5 skipped, 0 failures.

② Semver level

Unchanged by the delta: minor, fix(objectql)!:, Clause-②: no (narrowing), and the ADR-0087 marker byte-identical. CORRECT.

③ Boundary flags

  • No new flag from the delta. Q1 and Q2 of PASS 5828166480 stand.
  • The bigint clause is stated for a positive bigint; a negative one inverts. Not a must-change.

Implemented-by: claude/issue-20099-having-where-doors
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: FAIL

Must change (prose only):

  1. Changeset row 7, "what it did before": state that the answer depended on each value's string order against [object Object], not on the column's type, or state no per-type claim at all.
  2. PR body §1, the "reference naming no column" cell: the same correction.
  3. PR body §1, row 2: add "(the non-object values)" to "no group in the implicit slot".

…rder on a text column, measured on lowercase, uppercase and mixed text

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f08f8953fca34b5ae8f59eebfeab5fea52072862

Scope: the delta over FAIL 5828483617 (01f091f523): one commit and one file (.changeset/20099-having-where-doors.md, +1/−1, the row-7 "what it did before" cell), plus the PR-body edits.

  • git diff --stat 98abdf4ee3..f08f8953fc -- packages/ is empty, and the code and test blobs equal those of the PASS 5828166480 head.
  • The ADR-0087 marker line is byte-identical at all three heads.

Measured at base (engine.ts / having-filter.ts at their base blobs, verified, then restored by blob) through the public engine.aggregate:

  • on a real InMemoryDriver and SqliteWasmDriver, on both doors, on populated and empty sets;
  • over numeric, lowercase-text, uppercase-text, mixed-text and ISO-date columns (a groupBy column and a max alias), with four row-7 references under the six operators;
  • 1,864 cells per tree. No key differed across driver and door.

① Derived judgments

  • Delta shape: RIGHT. No code, test, marker, semver or clause line moved.
  • Changeset row 7: every clause TRUE at base.
    • "no group under $eq": 112/112.
    • "every group under $ne": 112/112.
    • "under an ordering operator, no group against a number column": 128/128.
    • "against a text or date column an answer that follows each value's string order against the text [object Object]": 320/320. The answered set equals exactly the values whose string comparison with [object Object] satisfies the operator.
      • Lowercase values keep every group under $gt / $gte.
      • Uppercase values and ISO dates keep every group under $lt / $lte.
      • Mixed values split accordingly.
    • The empty set answered [] on 672/672 cells.
  • PR body §1, the "reference naming no column" cell: TRUE, on the same measurements.
  • PR body §1, row 2 with the "(the non-object values)" qualifier: TRUE.
    • The qualifier carries weight: a bare { $field: 5 } in the implicit slot was refused at base.
    • Every per-operator clause holds, including the bigint exception and the Symbol TypeError.
  • The footer is RIGHT, and the session line is TRUE by blob id.
  • CI at this head: 41 runs, 36 success, 5 skipped, 0 failures, 0 pending. All seven required contexts are success.

② Semver level

Unchanged by the delta: minor, fix(objectql)!:, Clause-②: no (narrowing) in both the changeset and the PR body, and the ADR-0087 marker byte-identical. CORRECT.

③ Boundary flags

  • No new flag. Q1 and Q2 of PASS 5828166480 stand.
  • The three must-changes of FAIL 5828483617 are each discharged by measurement.

Implemented-by: claude/issue-20099-having-where-doors
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…user.can() from the security service (objectstack-ai#20082) (objectstack-ai#20138)

Fixes objectstack-ai#20082

Clause-②: no (narrowing)

A `formula` field and a CEL `defaultValue` that call
`current_user.can(object, verb)` now get the acting subject's effective
object permissions. That is the map PR objectstack-ai#20079 wired for option
visibility. It comes through
`ObjectQL.registerEffectiveObjectPermissionsResolver` and formula's
`toEvalPermissions`, and it is resolved at most once per engine
operation.

## The two sites, base against head

Measured one-shot through a real `ObjectQL` with a SQL driver
(better-sqlite3 `:memory:`) and a real `SecurityPlugin`. The caller
holds `crm_account: allowRead + allowEdit`, so `edit` is the granted
verb and `delete` the denied one. Base is `55daf89d74`; head is this
branch. The same cells are pinned permanently in
`packages/objectql/src/engine-formula-default-permission.test.ts` with a
resolver double.

| site | granted | denied | no resolver | resolver throws |
|:--|:--|:--|:--|:--|
| formula field on `find`, `findOne`, insert echo, update echo | base
`null`, no log; head `true` | base `null`, no log; head `false` | base
`null`, no log; head `null` plus one `warn` per operation, `reason:
'no-permission-source'` | base `null`, no log; head `null` plus one
`warn` per operation carrying the error, `reason:
'permission-resolution-failed'` |
| CEL `defaultValue` on insert | base unset plus `warn`; head stores
`true` | base unset plus `warn`; head stores `false` | unchanged: unset
plus the existing `warn`, which carries formula's "carries no permission
data" refusal | base unset plus `warn`, row written; head the insert is
refused with the resolver's own error, and nothing is written |
| a `required` field with that default | base refused
`VALIDATION_FAILED` / `required`; head admitted (`true`) | head admitted
(`false`) | unchanged: refused `VALIDATION_FAILED` / `required` | head
refused with the resolver's own error |

At base the resolver was asked 0 times by either site, whether it
granted, withheld or threw. That is H1, confirmed.

## The rule each site follows (H3)

- **Formula field (a read).** Today a formula that does not evaluate
reads `null` and logs nothing: `applyFormulaPlan` assigns `r.ok ? … :
null`. A read cannot refuse a row over one computed field, so a `can`
formula with no map keeps that `null`. It is no longer silent: the
engine logs one `warn` per operation naming the object, the `can` fields
and the reason. A throw fails closed. It is never read as "no grants",
which would make an empty map answer `false`, and never as a grant.
- **CEL default (a write).** Today a default that does not evaluate is
left unset with the `warn` "Failed to evaluate default expression". A
`required` field so defaulted is then refused by required-validation
(measured at base with the real plugin: `VALIDATION_FAILED`, field
`d_req`, code `required`). With no resolver that rule is kept byte for
byte: the absent member passes NO map. A throw fails closed the way the
option gate does. A row whose `can` default needed the map is refused
with the resolution's own error, re-raised untouched. Under `insertMany`
only that row is refused, and the `validate()` preview rejects the same
way. A row that supplies the field is never refused by a resolution it
did not need.

## Where the map comes from, and how often (H2)

- `permissionResolution(context)` is one lazy, memoised resolver ask per
engine operation. It is `undefined`, meaning no map, when no resolver is
registered or the operation has no acting user. The same conditions
apply to the option gate.
- **Read path.** `find` and `findOne` resolve after the driver returns,
and only when a planned formula calls `can` and at least one row came
back. They use `opCtx.context`, which is the context the security
middleware already ran with. So `plugin-security`'s per-context
permission-set memo serves the resolution.
- **Write path.** One resolution per write is shared by the CEL
defaults, the re-default after the static-`readonly` strip, the option
gates and the formula fields on the response. `resolveOptionPermissions`
now receives the write's resolution instead of calling the resolver
itself. When it asks, and what a throw does, are unchanged; its 13-case
suite passes as it was.
- The "needs the map" test for both sites is `readsPermissionPredicate`,
the option gate's own AST reading of a receiver `can` call. It is
exported from `rule-validator.ts` so that there is one detector, not
two. It is not re-exported from the package entry.

Resolver asks per operation (pinned):

| operation | base | head |
|:--|--:|--:|
| `find` over 4 rows with a `can` formula | 0 | 1 |
| `findOne`, and a by-id update echo | 0 | 1 each |
| insert with a `can` default and a `can` formula echo | 0 | 1 |
| insert that picks a `can`-gated option AND defaults a `can` field AND
echoes a `can` formula | 1 | 1 |
| batch insert of 6 rows | 0 | 1 |
| `validate()` over 2 rows | 0 | 1 |
| two consecutive `find`s | 0 | 2 (never kept across operations) |
| object with no `can` anywhere, even with a throwing resolver | 0 | 0 |
| system read (no acting user) | 0 | 0 |

## Declaration (H4)

- **Narrowing.** When the resolution fails, an insert whose `can`
default needed it is now refused. At base that row was written with the
field unset. So the changeset and this body carry `Clause-②: no
(narrowing)`, a **BREAKING** banner and `adr-0087: not-required
(no-migration-prescription)`. No authored key, stored shape, export or
route changes.
- **The claim reads `Clause-②: no`.** The arm is added here because the
dispatch's H4 says a write whose default now fails closed is a narrowing
to declare. The seat owns the claim line.
- **Reachability of that path.** In the shipped composition,
`SecurityPlugin`'s middleware resolves the same memoised permission sets
before the write and already refuses on a resolution failure. The newly
refused path is therefore reachable only when
`buildEffectiveObjectPermissions` throws, when the map is off-shape, or
with a third-party resolver.
- **Widening.** No key, export or route is added. A `required` field
defaulted by `can` is now admitted where it was always refused. That is
a declared default finally evaluating, not a new surface.

## Tests

Head is `4fcfbed346`. Each line names the commit it ran at. The only
commits after `9e622fbedd` edit the new test file: they type its options
objects and make its driver refuse unknown WHERE combinators.

- **Base red.** The new pin against `engine.ts` and `rule-validator.ts`
restored to `55daf89d74` (blob `a9ec130693` = base blob), then restored
to HEAD (blobs `15ca43c2eb` and `90cec7aea7` = HEAD, `git diff HEAD`
empty) gave `Tests 27 failed | 3 passed (30)`. For example, "expected
null to be true" (granted `find`), "expected [] to have a length of 1
but got +0" (no-resolver warn), and "expected ValidationError: flag is
required to be Error: permission store unreachable" (throw on a required
default). The 3 that pass are controls that must hold on both sides:
no-resolver on a required default, no `can` anywhere, and a system read.
This ran at `ca6dea2249`, with 30 cases; the 31st case, the `validate()`
rejection, was added after it.
- **Head (`4fcfbed346`).** The new pin plus
`engine-option-permission-predicate` gave `Test Files 2 passed (2)` and
`Tests 44 passed (44)`, which is 31 plus 13.
- **Ablation (at `ca6dea2249`; src-resolved, so no build).** I ran
`scripts/ablation-replace.mjs` to replace the memo's `pending ??=` with
`pending =`. The anchor went 1 to 0, the marker 0 to 1, and the blob
went `15ca43c2eb` to `592f152bbd`. The pin then gave `Tests 4 failed |
26 passed (30)`: every "one resolution" cell got 2 or 3 asks. The file
was restored with blob == HEAD and `git diff HEAD` empty.
- **`@objectstack/objectql`.** `vitest run --project local` gave `Test
Files 315 passed (315)` and `Tests 5373 passed (5373)`. It ran at
`9e622fbedd`; the only later commit retypes the options objects in the
new test file. `--project repo` gave 1 file, 5 tests passed. `typecheck`
(src, scripts and the test layer) exited 0 at `4fcfbed346`, and the test
layer still holds 40 files and 234 errors in the debt ledger. The new
test file is in `tsconfig.test.json`'s program (`--listFiles`) with 0
errors.
- **`@objectstack/plugin-security` (`9e622fbedd`).** The full suite, run
with `objectql` aliased to source, gave `Test Files 135 passed (135)`
and `Tests 2676 passed (2676)`.
- **Dogfood (`55ec8feee6`).** On a closure built by turbo (64 tasks),
`field-zoo-roundtrip`, `field-zoo-value-shape`,
`showcase-static-readonly` (the re-default path),
`showcase-fls-read-mask-strip` and `expression-conformance` gave `Test
Files 5 passed (5)` and `Tests 109 passed (109)`.
- **Targeted neighbours (`9e622fbedd`).**
`engine-option-permission-predicate`,
`rule-validator.option-visibility`, `engine-write-formula-hydration`,
`engine-formula-scale`, `engine-cel-default-temporal-shape`,
`engine-default-value-tokens` and `record-title`, run with the new pin,
gave `Test Files 8 passed (8)` and `Tests 170 passed (170)`.
- **eslint, narrowed (`4fcfbed346`).** `eslint --no-inline-config
--format json` on the 3 changed `.ts` files found 3 files, 0 errors and
0 warnings. The population is read from `eslint.config.mjs`, and no file
was reported ignored. The config sets no `parserOptions.project`, so no
type-aware rule can move a verdict on an untouched file.

## Gates

- **Derivation.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `4fcfbed346` derived 65 commands.
- **Run.** All 65 were run at `4fcfbed346`, and all 65 exited 0.
- **Reconciliation.** `--ran` reported `65 derived, 65 run, 0
NOT-MEASURED, 0 UNRUN`.
- **Fixed on the way.** Two families were red at `9e622fbedd` and are
green at head:
- `check:query-options-erasure`: the new test's options objects were
erased to `any`, and the test surface grew from 236 to 244 sites. They
are now typed, and the surface is back to 236.
- `check:where-matcher`: the test driver read an unknown `$` key as a
field name. It now refuses it.
- **Prerequisite, then measured.** `check:dual-build-cjs-loads` exited 3
(PREREQUISITE NOT MET) on the first pass. Later gates in the list built
the missing dists, and it exits 0 at head. A CJS/ESM load probe of
`packages/objectql/dist` sees `ObjectQL` and `evaluateFormulaField` on
both.
- **`check-changeset-no-major`, fed this body as a `pull_request`
payload (`--event`).** It reported `LEVEL AXIS: this PR declares
clause-② no (narrowing), and no package whose packages/**/src/** it
moves is graded patch`.
- **`check-adr-0087-registration`.** It reported `1 declared-breaking
changeset(s), each carrying an ADR-0087 disposition` and
`[BREAKING+clause-②-narrowing] not-required
(no-migration-prescription)`.
- **`check-issue-citations --base 55daf89`.** Exit 0: `17 resolves`.
- **Left to CI.** The derivation names 5 path-scheduled CI jobs and 4
type-check lanes. They are CI's own runs, and they are NOT MEASURED
locally.

## Acceptance notes

- `carrier:` 承接者:无. The expression-conformance ledger row `cel-formula`
declares `failPolicy: 'fail-soft-log'`, but a formula that faults for
any other reason still reads `null` with no log line
(`applyFormulaPlan`'s `r.ok ? … : null`). This PR logs only the `can`
case, which is its own. This is read off the code, not measured at a
public door.
- `carrier:` 承接者:无. `evaluateFormulaField` and `resolveRecordTitle` are
synchronous and hold no resolver, so a title formula calling `can` still
yields `null` there. The docblock and the changeset say so.
- `carrier:` 承接者:无. Each `expand` of a related object is its own `find`,
so it asks the resolver again. `plugin-security`'s per-context memo
absorbs the set resolution; the map itself is rebuilt.
- `carrier:` 承接者:无. A system read, which has no acting user, of a `can`
formula reads `null` with no warn, as any `current_user` formula does
with no subject.

## Deviations from the claim's file surface

- `packages/objectql/src/validation/rule-validator.ts` gets `export` on
`readsPermissionPredicate`, plus a three-line docblock note, so that
there is one `can` detector.
- In `engine.ts`, beyond the bodies of `applyFormulaPlan` and
`applyFieldDefaults`:
- their call sites in `find`, `findOne`, `insert`, `update` and
`validate`;
- `hydrateWriteFormulas`, which is now async and takes a permissions
callback;
  - four private helpers;
- `resolveOptionPermissions`, which now takes the shared resolution. H2
requires that for "at most once per write" across both uses. Its
behaviour is unchanged.
- `packages/core`, `packages/formula` and PR objectstack-ai#20117's `aggregate` region
are untouched.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants