Skip to content

fix(objectql,driver-mongodb,formula): having compiles the whole-day bound it is handed; $contains asks membership on a JSON-stored field (#20822 group 3b) - #21196

Merged
objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-20822-g3b-having-contains-tail
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-20822-g3b-having-contains-tail

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Part of #20822
Clause-②: no

Group 3b of #20822 (#5930 step 4, the engine lane), under claim 5930666311. It carries F8, the three #20987 engine faces from triage pointer 5922592744, the stale F1 pointers, and the docs sentence. F7 (lteBound) is untouched: its card #21109 was ruled A at 14:16Z and remains open, and the deletion waits until that card's PR is on main.

What changes

face change commit
F8, objectql having and aggregations[i].filter walker (having-filter.ts) the whole-day copy is deleted: wholeDayUpperBound, its two arms, and the nextUtcCalendarDay / UNBOUNDED_ABOVE imports 4256b7c, e7006d8
driver-mongodb translateFieldOperators $contains / $notContains ask membership on a declared JSON-stored field (array-only $elemMatch over jsonMembershipCandidates from @objectstack/core) 098beef, 9080dd0, 565a47a
formula matchesFilterCondition $contains / $notContains ask membership by the column's declaration when options.fields names it, else by the stored value (seat answer Q2 = C, 5926601042) a525d25, ff3127e
objectql engine.ts delete-probe docblock comment only: membership on every typed backend, and the superset reading only without a declaration 1faf2e9
spec filter.zod.ts docblock only: the three pointers to the deleted SqlDriver.calendarDay*Rewrite name lowerFilterCondition; the $contains implementation-status list gains driver-mongodb and formula 74d434e
read-scope-shared-lowering-seam.test.ts case titles and header: a guard without types no longer hands the RLS using bound as written 74d434e
query-syntax.mdx the direct-call sentence names InMemoryDriver, MongoDBDriver and applyInMemoryAggregation beside the SqlDriver family, and the aggregate positions the engine lowers 74d434e

F8: measured first (one grep, one probe)

  • Grep. applyHaving and matchesHaving are not exported from either objectql entry. matchesAggregationFilter is reached through applyInMemoryAggregation, which both entries export. In-repo callers are engine.aggregate (seam-fed) and packages/verify/src/date-bucket-parity.ts, whose ASTs carry no per-aggregation filter.
  • The seam covers both positions with the same reader the copy used. engine.aggregate resolves then lowers aggregations[i].filter with declaredDatetimeLowering(schema), and having with aggregatedRowColumnTypes(...) === 'datetime'. The copy's set was classOfDeclaredType(type) === 'datetime', and INSTANT_TYPES is { datetime }, so the two sets are equal. With no field map, the seam is type-blind on the per-aggregation filter and passes no column on having; the copy passed none on either. Nothing composes into having or an aggregation filter after the seam (predicate-guard.ts only reads them).
  • Probe. On a datetime field, { opened_at: { $lte: '2026-02-01' } } over 6 rows. Through engine.aggregate, before and after: 3 (the whole day). Through applyInMemoryAggregation(rows, ast, undefined, fields) called directly: 3 before, 2 after (that day's midnight, as written). That is item 5. Group 3a graded the same move on F6 as no.

The $contains faces

Each face is pinned on u1 against a stored ["u10"], with a scalar control:

  • mongodb: { owners: { $contains: 'u1' } } on a multiple: true lookup emits { owners: { $elemMatch: { $in: ['u1'], $not: { $type: 'array' } } } }. It used to emit $regex: 'u1', which MongoDB applies per element. A text field keeps $regex. A field the driver holds no declaration for keeps $regex, as driver-sql does for a table it was never told about.
  • formula: matchesFilterCondition({ owners: ['u10'] }, { owners: { $contains: 'u1' } }) is false, ['u1', 'u2'] is true, and { title: 'u10' } is true (substring).

The declaration H2 asked about: MongoDBDriver reads it through ValueShapeResolver once syncSchema has run, and a direct translateFilter call gets none. jsonMembershipCandidates (core, PR #21117) supplies the candidates, parsed from JSON text into values. driver-mongodb already depended on core. Formula depends on spec alone, so it carries a value-level copy of the same candidate rule, as objectql having and driver-memory do (see the acceptance notes).

The emitted mongo documents were also read through mingo 7.2.4 (driver-memory's evaluator) in a scratch probe. It agreed with the server-free reader on every new case. A real mongod was NOT MEASURED: there is no binary here, and the live block in the new test file is skipped. That is the card's recorded gap.

RLS effect of the formula face (H3)

The write check evaluates check with matchesFilterCondition, handed the object's declared columns. The probe ran through ObjectQL, SecurityPlugin and SqlDriver (better-sqlite3 and sqlite-wasm, identical). Policy: record.tags.contains('x') on a tags field. The "before" column is formula's pre-change arm, ablated in dist/.

post-image tags stored as read under using check before check after
['x'] ['x'] shown 403 admitted
['a', 'x'] ['a', 'x'] shown 403 admitted
['xy'] ['xy'] hidden 403 403
scalar 'xy' ['xy'] hidden admitted 403
scalar 'x' ['x'] shown admitted 403
null null hidden 403 403

Clause-② (H5)

no, as claimed:

  • No face adds or removes a refusal, and no export, type member or authorable key changes.
  • The answers move toward the declared contract. On driver-mongodb, $contains narrows on declared JSON-stored fields (exact member instead of a per-element substring). On formula, it widens on arrays and narrows on a scalar stored in a declared JSON-stored column. On objectql, a direct call compares as written.
  • The reviewer should re-judge one line: through the RLS write check, formula's move becomes an admit-set move in both directions (the table above).

Levels: @objectstack/objectql, @objectstack/driver-mongodb, @objectstack/formula and @objectstack/spec are patch. The spec entry is docblock-only: filter.zod.ts ships in the spec tarball (files includes src/**/*.zod.ts), so its edited docblocks publish (patch round 1, 9a797d0, after review 5935291820). The docs and test edits do not publish.

Ablations (on committed heads; every restore proven blob == HEAD and git diff HEAD empty)

  • F8 A1 re-plants the $lte whole-day arm (nested WRAP: import, then arm; objectql tests import src). 3 red of 798, exactly the direct-call $lte cells (per-aggregation $lte, applyInMemoryAggregation, having on min(datetime)). Every seam-fed cell stays green: the card's rows 3 and 4, the having rows, the temporal kit, and engine-shared-filter-lowering-seam.
    • The first A1 attempt was a no-op. Its replacement contained its own anchor, the tool refused it ("the anchor count moved 1 -> 1"), and it was rerun with a respelled import.
  • F8 A2 re-plants the $between arm. 1 red of 798, exactly the direct $between cell.
  • M1 restores the always-$regex arms in mongodb-filter.ts. 10 red of 690, all membership cells in the new file. The scalar controls, the no-declaration cell and all pre-existing suites stay green.
  • C1 forces containsAsksMembership to false in formula, then rebuilds formula, which plugin-security consumes through dist/.
    • The first attempt is VOID. That mutation failed the DTS build (unused symbols), and esbuild folded its marker string, so ablation-dist-preflight reported the marker absent from dist/ (exit 1).
    • The rerun used a marker esbuild keeps. Build exit 0, preflight found the marker in 2 built files. formula: 8 red of 1253, all membership cells. plugin-security: 4 red of 6, the check insert and update cells on both drivers, with both using read cells green.
    • The restore leg rebuilt formula; preflight --absent passed and the tree was clean.

Tests (final head a62f5ff, vitest run --maxWorkers=2, under the verify lock)

package files tests
objectql (--project local) 359 passed 7078 passed
driver-mongodb 31 passed, 5 skipped 690 passed, 182 skipped (base 675 / 172; +15 / +10 is the new file and its live block)
formula 43 passed 1253 passed (base 1241)
plugin-security 155 passed 3327 passed, 23 skipped
service-analytics read-scope-shared-lowering-seam 1 passed 12 passed
  • At BASE f0cc16e, the objectql having/aggregate subset was 21 files and 783 passed. After the deletion, before any test edit, it was still 783.
  • typecheck (tsc plus check:test-typecheck) exits 0 for objectql, driver-mongodb, formula, plugin-security and service-analytics.
  • spec check:generated: 15 of 15 up to date.
  • check:driver-conformance reads the same before (BASE) and after (head): OK, 50 covered cells, 0 DEBT, 0 exempt.
  • Lint, narrowed. eslint --no-inline-config --format json over the 11 changed .ts files at a62f5ff: 11 files, 0 errors, 0 warnings, none ignored. The .md / .mdx files are outside eslint's configured population ("no matching configuration"). eslint.config.mjs enables no type-aware linting, so no untouched file's verdict can move. The full pnpm lint is CI's.
  • Gates. dispatch-gates --commands at a62f5ff derived 115 families from 13 paths. All 115 were run with exit codes recorded and all exited 0. --ran: 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN.
    • On the first pass three gates refused with PREREQUISITE NOT MET: check:skill-examples, check:dual-build-cjs-loads and check:i18n. They passed after a full turbo build.
    • check:where-matcher caught the new mongodb test double, whose control probe threw on implicit equality; 565a47a fixes it.

Acceptance notes


Generated by Claude Code

claude added 12 commits October 1, 2026 13:24
…ritten; the seam lowers it

The having walker and the per-aggregation filter walker no longer apply the
whole-day upper bound themselves (ADR-0053 D-D1 item 5, as amended). Pins the
two halves: a direct call (matchesAggregationFilter, applyHaving and the public
applyInMemoryAggregation) compares as written, and the same filter lowered by
lowerFilterCondition with the engine's readers answers the whole day.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…red field

translateFieldOperators wrote $regex for $contains on every field, and MongoDB
applies a $regex to each element of an array value, so 'u1' matched a stored
['u10']. On a field whose declared shape is JSON-stored (STRUCTURED_JSON_TYPES
or isMultiValueField) it now emits an array-only $elemMatch over the members
@objectstack/core's jsonMembershipCandidates names, and $notContains its exact
complement. A scalar column, and a field whose declaration the driver does not
hold, keep the substring test.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
… in the no-declaration pin

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…ts declaration or else its stored value

matchesFilterCondition answered $contains by substring alone, so a stored array
never matched it and always matched $notContains. The question now follows the
FILTER_OPERATORS $contains contract: the column's declaration decides when the
caller supplies it (membership on STRUCTURED_JSON_TYPES or a multi-valued field,
substring on any other), and the stored value's shape decides otherwise (an
array asks membership, anything else substring), the by-value split this face
already gives $empty. The member candidates are the set the SQL dialects bind.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…swers membership on read and on the write check

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…membership on every typed backend

Comment only. The docblock said every backend answers $contains by substring, a
superset; driver-sql, driver-memory and now driver-mongodb answer membership on a
declared multi-valued column. It now says so, keeps the superset reading for a
backend without the declaration, and names the off-shape bare-scalar slot the
array-only membership test does not reach.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…the shared lowering; the contains contract lists mongodb and formula

Docblock, test-title and docs prose only. filter.zod.ts named
SqlDriver.calendarDayUpperBoundRewrite / calendarDayBetweenRewrite, deleted with
F1; they now name lowerFilterCondition at the seams. The FILTER_OPERATORS
$contains implementation-status list gains driver-mongodb and formula. The
read-scope seam test no longer says a guard without types hands the RLS using
bound as written (the RLS seam lowers it type-blind since the copies went).
query-syntax.mdx's direct-call sentence names every driver that now compares a
direct filter as written, and the aggregate positions the engine lowers.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…implicit equality

check:where-matcher discovered the double and its control probe (implicit
equality) threw; it now answers it the MongoDB way and still refuses the
combinators it does not model.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/driver-mongodb, @objectstack/formula, @objectstack/objectql, @objectstack/spec, touching 21 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/filter.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via MongoDBDriver (symbol, a top-level class), syncSchema (symbol, a method of class MongoDBDriver))
  • content/docs/protocol/kernel/lifecycle.mdx (via syncSchema (symbol, a method of class MongoDBDriver))
  • content/docs/protocol/objectql/query-syntax.mdx (via MongoDBDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via syncSchema (symbol, a method of class MongoDBDriver))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via syncSchema (symbol, a method of class MongoDBDriver))
  • content/docs/releases/v17/17-0.mdx (via MongoDBDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/data/filter.zod.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 70 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 139 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 cb45469e6765648a4886073b044af1854f7c31cb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f11768faf0d5688a4c0e08a523941ff237fc3219 — the merge of head 966893c3aca83ce2c3386efc434bf96f58acd5a9 into base cb45469e6765648a4886073b044af1854f7c31cb, 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 f11768faf0d5688a4c0e08a523941ff237fc3219 && git checkout f11768faf0d5688a4c0e08a523941ff237fc3219
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cb45469e6765648a4886073b044af1854f7c31cb 966893c3aca83ce2c3386efc434bf96f58acd5a9 && git checkout -B drift-repro cb45469e6765648a4886073b044af1854f7c31cb && git merge --no-ff 966893c3aca83ce2c3386efc434bf96f58acd5a9

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs cb45469e6765648a4886073b044af1854f7c31cb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a62f5ff1031b684a42b09ed0731342217eaa7f49
Local-runs: none

Inputs: card #20822 (body and all 34 comments), PR #21196 (body, 13 files, the net diff against main at merge base 2c1cef334), and the 32 check-runs on the head. Context for the diff was read from the branch ref only (git show / git grep), nothing built, run or re-run.

Check-runs on the head (their conclusions are the gate verdicts): 14 success — Auto Label, Build Docs, Type Check · source gates, filter, No other open PR may claim the same issue, Part-of PR must not also close its card, Governed Surface Queue Guard, No other open PR may claim the same single-writer path, Spec property liveness, The card this PR closes must claim this branch, Check PR Size, Check Documentation Links, Check Changeset, Flag docs affected by code changes. 2 skipped — Console Pin Gate, Packed-tarball smoke (opt-in). 0 failed. 16 still in_progress, each NOT a verdict: Test Core (1/6 to 6/6), Build Core, Lint & Repo Gates (the job that carries check:generated --reconcile-only and the full pnpm lint), Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (1/3 to 3/3), Dogfood Verify CLI. Not waited on.

① Derived judgments

F8, deleted — right.

  • The diff removes wholeDayUpperBound, its $lte and $between arms, the nextUtcCalendarDay / UNBOUNDED_ABOVE / isUnboundedAbove import, and with that docblock the stale SqlDriver.calendarDayUpperBoundRewrite pointer (the having-filter.ts:1365 item). $lte now compares stored against form(target) as written; $between compares both ends as written.
  • The seam covers both positions with the copy's datetime set, read in code at this head. engine.aggregate lowers each aggregations[i].filter through resolveThenLowerWhere with declaredDatetimeLowering(registry.getObject(object)), whose reader is fields[column].type === 'datetime' (type-blind {} with no field map, group 1b's item 7). It lowers having through resolveWhereTokens with an isDatetimeColumn reader that answers aggregatedRowColumnTypes(groupBy, aggregations, declaredFields).get(column) === 'datetime'. The deleted copy's set was classOfDeclaredType(type) === 'datetime', which is INSTANT_TYPES.has(type), and INSTANT_TYPES is { datetime } (spec field-value.zod.ts:79); aggregatedRowColumnClasses is classOfDeclaredType over the same aggregatedRowColumnTypes. Equal sets on both positions. Where the seam is wider (no field map on the per-aggregation filter), the copy did nothing and the walker now compares the already-lowered $lt as written, so no seam-fed answer moves. The seam's 9999-12-31 reading ($null: false) and its $between split ($gte plus $lt) land on arms the walker already has. Nothing composes into either position after the seam.
  • Direct door: applyInMemoryAggregation is exported from both objectql entries (index.ts:366, core.ts:78); applyHaving and matchesHaving are not. In-repo callers are engine.aggregate (seam-fed) and packages/verify/src/date-bucket-parity.ts:239 (no per-aggregation filter). A direct call now compares a bare-day upper bound as written: ADR-0053 D-D1 item 5, the grade group 3a gave F6 and group 1b gave F3. Right.
  • Ablation counts against the diff's cells: the new block in engine-aggregate-temporal-storage-rule.test.ts has four as-written cells — per-aggregation $lte, per-aggregation $between, applyInMemoryAggregation with a $lte, and having $lte on min(datetime). A1 (re-plant the $lte arm) 3 red = exactly the three $lte cells; A2 (re-plant the $between arm) 1 red = exactly the $between cell. The lowered expectations in the same block cannot move under either re-plant, since the lowered form carries $lt. Consistent.

mongodb $contains / $notContains — right, and the declared contract.

  • isJsonStoredShape(shape) is STRUCTURED_JSON_TYPES.has(type) || isMultiValueField(shape), and spec's isMultiValueField is MULTI_OPTION_TYPES (tags, multiselect, checkboxes) or a multi-capable type with multiple === true — the two halves the FILTER_OPERATORS $contains docblock names ("a multiple: true field or a JSON-stored type") and the same population driver-sql's JSON-column registry and driver-memory read. indexValueShapes records { type, multiple } at syncSchema, and valueShapeFor(object) is handed to translateFilter at every verb at this head (mongodb-driver.ts :367, :387, :532, :603, :621, :725, and the aggregation $match through buildAggregationPipeline.valueShape at :657, a parameter that already existed at the merge base). No declaration gives false, so the substring $regex stays, matching SqlDriver.isJsonColumn for a table it was never told about.
  • The candidate rule is the shared one: containsMembers(value) is jsonMembershipCandidates(value).map(JSON.parse); core exports it (packages/core/src/index.ts:59, export * from './utils/json-membership-sql.js'), and @objectstack/core is under driver-mongodb's dependencies. The test is $elemMatch: { $in: members, $not: { $type: 'array' } } — array-only, nested arrays excluded, the SQL constructs' shape; $notContains is $not over it and admits null, missing and non-array values (the IS NULL OR NOT shape). $elemMatch is a key no other operator writes, so A field operator whose lowering reuses another operator's key silently CLOBBERS it — $null, $between and $notContains on driver-memory and driver-mongodb, key-order dependent #13524's clobber rule is untouched. translateFieldOperators is module-private and translateFilter's signature is unchanged: no exported surface moves.
  • Pinned as triage 5922592744 asked: u1 against a stored ["u10"] plus a scalar control, on emitted documents read server-free and cross-checked with mingo. A real mongod is NOT MEASURED (the live block is skipIf); that is the card's recorded gap, restated, not a new one.

formula $contains (Q2 = C) and the RLS write check — right; the stop condition is not met.

  • containsAsksMembership(actual, declared): with a declaration, STRUCTURED_JSON_TYPES.has(type) || isMultiValueField({ type, multiple: declared.multiple === true }); without one, Array.isArray(actual). CrossFieldComparisonFieldMeta carries multiple, and plugin-security's declaredComparisonColumns (declared-comparison-columns.ts:30-37) hands { type, multiple } for every declared field to both the write check (writeCheckFieldOptions) and the explain engine — so a lookup with multiple: true gets membership through the real check, not only the tags type the pin uses. The declaration is threaded through evalNode into $and / $or / $not subtrees. This is seat note 5926601042's C exactly, with the $empty precedent ([Decision] How does a filter say 「is empty」 on a multi-value field? A declared $empty operator, or reopen the empty-list refusal (ruling B on #20311, its third arm) #20399 ruling A) as its ground.
  • The write check (security-plugin.ts:3275-3278) evaluates check on the raw post-image with those columns. The report's six-row table, judged: arrays holding the member are admitted and the read shows them; ['xy'] and null are refused and the read hides them; scalar 'xy' is refused where the base admitted it while the read hid the stored ['xy'] — a base fail-open closed; scalar 'x' is refused while the stored ['x'] is shown — fail-CLOSED: a write refused, no hidden row admitted. No write path now admits a row the same policy's read hides. With a declaration both sides ask membership; with none (writeCheckFieldOptions returns undefined when the schema cannot load) the check judges by value and a driver holding no declaration also reads substring — the base's reading on both sides, unchanged by this diff. The card's security stop is not met. The scalar-'x' cell is [Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109's raw-vs-stored class (ruled A, the check judges the stored form); routing it there rather than working around it here is right, and A's fold re-admits it.
  • Every admit-set move on a declared path is toward the ruled contract (the FILTER_OPERATORS $contains docblock, maintainer ruling 2026-09-12).

The three value-level copies — a declared duplication, not a wrong answer at this head. formula storedArrayHasMember (this PR), objectql having-filter.ts storedArrayHasMember (#20873, on main before this PR) and driver-memory containsMemberCandidates (#20874) carry a JSON_NUMBER_TEXT regex byte-identical to the one inside core's jsonMembershipCandidates, and the same true / false / null text rule. formula's package.json depends on @objectstack/spec and cel-js alone, so it cannot import core. formula's new pins ('1' names 1, '1.50' names 1.5, '0x10' does not name 16, 'true', 'null', a nested array or object is no member) are the cells core's own test pins, so a drift on either side shows in a pin; no cross-package pin is possible under the dependency rule. The home every docblock names is @objectstack/spec/data. Escalated in ③.

spec filter.zod.ts — comments only, and truthful. Every hunk is inside a docblock (the description consts at :353, :661 and the $contains block closing at :1066). The three calendarDay*Rewrite pointers now name lowerFilterCondition at the seams, true since ceee88f46. The two new implementation-status entries state what the diff does: mongodb forks on the declared shape through jsonMembershipCandidates, measured on emitted documents and mingo, real mongod not measured, no declaration keeps substring; formula reads the declaration when supplied and the stored value's shape otherwise, measured through plugin-security on SQLite. check:generated is the report's 15/15 and CI's Lint & Repo Gates, in progress. One pre-existing line in that list is unqualified: "driver-sqlite-wasm and driver-turso inherit it" — Turso's REMOTE face still compiles $contains as pushLike(..., 'contains') (remote-transport.ts:3013-3016 at this head), #20987's open item; not this PR's edit, flagged in ③.

engine.ts delete-probe docblock — comment only, truthful: membership on every backend holding the declaration (the SQL family's per-dialect construct, memory's $elemMatch, mongodb's $elemMatch), the superset reading only without a declaration, the exact narrowing kept in storedReferenceIncludes, and the array-only caveat for an off-shape bare scalar.

Docs, the query-syntax.mdx sentence — true at this head. The engine's where, per-aggregation filter and having seams and the RLS compile seam lower first; a filter handed directly to the SqlDriver family, InMemoryDriver (group 1b), MongoDBDriver (group 3a) or applyInMemoryAggregation (this PR) is compared as written. The sentence does not claim matchesFilterCondition compares as written, so F7's live copy makes nothing in it false.

read-scope-shared-lowering-seam.test.ts — titles and header now say a guard without types is lowered type-blind by the RLS seam (group 2's rlsLowering) and the as-written row is a producer that skipped that seam. True since ceee88f46.

F7 (lteBound) untouched — confirmed in the diff: matches-filter.ts keeps lteBound and both call sites (the $between arm's lteBound(actual, v[1]) is in the hunk's context). Right under seat note 5926601042 and #21109.

② Semver level

  • Clause-②: no, in the PR body and in the changeset — right. No export, type member, authorable key or refusal class is added or removed on any face; translateFilter's and matchesFilterCondition's signatures are unchanged, and translateFieldOperators is private. The (narrowing) arm is for a removed or narrowed callable or authorable surface (group 2's grade for SqlDriver's three protected methods, 5922273550); nothing of that kind moves here. The RLS admit set moving both ways is policy evaluation over data, declared cell by cell in the changeset with the author's fix ("Send the list, tags: ['x']"), toward the ruled contract, with the one counter-contract cell fail-closed and routed to [Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109; it makes the clause neither yes (no package gains a surface) nor (narrowing). patch for @objectstack/objectql (a direct door compares as written, item 5 — the same grade as F6 in group 3a and F3 in group 1b), @objectstack/driver-mongodb and @objectstack/formula (answers aligned to the declared contract) is right.
  • FAIL — one item: the changeset omits @objectstack/spec. packages/spec/package.json ships src/**/*.zod.ts in its files, so the four edited docblocks in filter.zod.ts publish verbatim in the spec tarball; the report's own H4 concedes "these bytes publish". AGENTS.md Post-Task Checklist step 3 is "Add a changeset for anything that publishes … never none", and this card's own precedent — ACCEPT 5915623622, the seat as reviewer of record — requires "a spec patch entry for the shipped line" for exactly this class, a stale comment in a shipped spec source. The report's "comments fast lane" names no rule: AGENTS.md, the changeset gates and the pm-dispatch skill carry no such lane, and check-changeset-no-major.mjs says in its own header that it refuses a comment-only exemption. Check Changeset's green reads the PR-scoped clause-② line against the moved packages' levels, not per-package coverage, so it is not a verdict on this. Owed, one commit: '@objectstack/spec': patch in the frontmatter and one bullet — docblock-only: the three calendarDay*Rewrite pointers now name lowerFilterCondition at the seams, and the $contains implementation-status list gains driver-mongodb and formula. Clause-②: no is unchanged by it; everything else in this record carries over to a delta on that head.

③ Boundary flags

open_questions is []. The report's deviations, acceptance notes and out-of-scope findings, each answered or escalated:

  1. Spec docblock widened beyond the three pointer lines (the $contains list gains two entries) — answered: declared, truthful (①), inside the claim's "docblock only"; it is the contract docblock stating the faces this PR moves. It does not alter ②'s item, which is about the missing changeset line, not the edit.
  2. engine.ts docblock names the off-shape bare-scalar slot — answered: true of array-only membership on every declared backend, already so on sql and memory, the exact narrowing in storedReferenceIncludes is kept, no producer measured; carrier none is acceptable.
  3. Seam test header's two corrected sentences — answered: true since group 2 landed.
  4. Three value-level copies of the member-candidate rule — answered as a declared duplication (①); escalated: the shared home every docblock names, @objectstack/spec/data, owes a card so formula, the having walker and driver-memory import one value-level predicate instead of carrying copies. The report marks it carrier-none; the seat files it in the spec lane. The 3a claim's "never a fourth copy" was overtaken by the dependency fact (3a H3) and the Q2 = C answer; at this head the copies agree byte for byte.
  5. Ablation reruns (C1's first attempt void, A1's first attempt a refused no-op) — mechanics, declared; the final counts match the diff's cells (①).
  6. Baselines on f0cc16e8d, main merged twice — the net diff against merge base 2c1cef334 is what this record judges; nothing in the merges is attributed to the PR.
  7. out_of_scope[0], scalar 'x' refused under a contains check — answered: fail-closed, correctly routed to [Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109 (ruled A), where the stored-form fold resolves it.
  8. out_of_scope[3], query-syntax.mdx's "$contains takes TEXT" bullet — answered: [finding] $contains / $notContains on a declared multi-valued or JSON-stored field still answer SUBSTRING on five faces, the analytics RLS read scope among them (u1 admits a row storing u10) #20987's item (5922379046), that card is open, carrier stands.
  9. out_of_scope[4], compileScopedFilterToSql with no declarations reads no column as datetime — escalated: this is the second reading of "cannot read types" that seat answer 5918373748 said item 7 does not allow (the RLS seam got its type-blind twin in group 2). Both in-repo consumers hand declarations, so no public door moves today, but it needs a named carrier in the services lane (step 3's read-scope seam, the #5930 step 3: the shared filter lowering at the analytics seams (the analytics where / preview door, the read scope) and the memory cube face's door, with the F5 / F11 output vocabulary #20810 / [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 family), not "none".
  10. Real mongod NOT MEASURED — the card's recorded gap; the live block is opt-in and skipped here; Test Core is in progress.
  11. Turso remote $contains — the pre-existing filter.zod.ts line "driver-turso inherit[s] it" is unqualified while the remote face is still substring at this head; carrier [finding] $contains / $notContains on a declared multi-valued or JSON-stored field still answer SUBSTRING on five faces, the analytics RLS read scope among them (u1 admits a row storing u10) #20987's Turso item (5923177087). Not this PR's edit.
  12. Open-tail reminder — the group-1 matcher-pointer carry (ACCEPT 5915623622: spec filter-logic-conformance.ts:15, the formula / service-analytics / service-storage test docblocks, plugin-security claim-seed-ownership.ts:91, service-analytics objectql-strategy.ts:1929, the design doc's F4 row) was assigned to "the card's last group PR" and is in neither the 3b claim nor this diff. This PR is not the last (F7 waits on [Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109), so nothing is breached here; the F7 PR's claim must name that list, or the card closes with a tail.
  13. 16 checks in progress — not verdicts (listed above). The record reads the 14 green and 2 rostered skips; the Test Core, Build Core, Lint & Repo Gates (check:generated), Type Check and Temporal Conformance verdicts land after this record.

Implemented-by: claude/issue-20822-g3b-having-contains-tail
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: FAIL


Generated by Claude Code

claude added 2 commits October 1, 2026 16:02
…dits

packages/spec ships src/**/*.zod.ts, so the docblock-only edits in
filter.zod.ts publish; the changeset now names @objectstack/spec at patch with
one docblock-only bullet. Clause-②: no is unchanged.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 966893c3aca83ce2c3386efc434bf96f58acd5a9
Local-runs: none

Round-1 delta review on the head after FAIL 5935291820 @ a62f5ff1031b684a42b09ed0731342217eaa7f49. Inputs: card #20822 (body and all 36 comments, the seat's patch decision 5935317796 and the patch-round report 5935438556 included), PR #21196 (body, 13 files, the net diff against main at merge base cb45469e67), and the 39 check-runs on this head. Diff context was read from the branch ref only (git show / git grep); nothing built, run or re-run.

The two commits after a62f5ff10:

Check-runs on this head (their conclusions are the gate verdicts): 21 success: Auto Label, Build Docs, Check Changeset (twice: the 16:03 push run and the 16:08 body-edit run), Check Documentation Links, Check PR Size, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Spec property liveness, Type Check · source gates, Type Check · debt ledger, filter, and the four claim guards (No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch), each green on both events. 4 skipped: Console Pin Gate, Packed-tarball smoke (opt-in), and the body-edit event's Auto Label and Check PR Size, the same four kinds this card's earlier ACCEPTs record as rostered. 0 failed. 14 still in_progress, each NOT a verdict and not waited on: Build Core, Test Core (1/6 to 6/6), Dogfood Regression Gate (1/3 to 3/3), Lint & Repo Gates (carries check:generated and the full pnpm lint), Type Check · consumer gates, Type Check · workspace, Temporal Conformance (live PG + MySQL).

① Derived judgments

Every ① judgment of 5935291820 re-confirmed at this head, against the diff and the ref:

  • F8, deleted — right, still. having-filter.ts at this head has 0 hits for wholeDayUpperBound, nextUtcCalendarDay, UNBOUNDED_ABOVE, isUnboundedAbove and calendarDay; the $lte and $between arms compare stored against form(target) as written. The seam still covers both positions with the copy's set: engine.aggregate lowers each aggregations[i].filter through resolveThenLowerWhere with declaredDatetimeLowering (engine.ts:17382, :17397) and having with aggregatedRowColumnTypes(...) (:17416); INSTANT_TYPES is { datetime } (field-value.zod.ts:79), and aggregatedRowColumnClasses is classOfDeclaredType over the same types (having-filter.ts:901-908). Direct door unchanged: applyInMemoryAggregation is exported from index.ts:366 and core.ts:78; applyHaving / matchesHaving are not; in-repo src callers are engine.ts and verify/src/date-bucket-parity.ts. ADR-0053 D-D1 item 5, the grade F6 (3a) and F3 (1b) took. The A1 / A2 ablation counts still match the four as-written cells in the new test block.
  • mongodb $contains / $notContains — right. isJsonStoredShape reads STRUCTURED_JSON_TYPES.has(type) || isMultiValueField(shape) (spec field-value.zod.ts:320, :355), the two halves the FILTER_OPERATORS docblock names. valueShapeFor(object) is handed to translateFilter at every verb (mongodb-driver.ts :367, :387, :532, :603, :621, :725) and to the aggregation $match (:657); no declaration keeps $regex. containsMembers is jsonMembershipCandidates(value).map(JSON.parse); core exports it (core/src/index.ts:59) and @objectstack/core is in driver-mongodb's dependencies. $elemMatch with $not: { $type: 'array' } is array-only and nested-array-free; $notContains is $not over it. translateFilter's signature is unchanged; translateFieldOperators is private. Real mongod NOT MEASURED, the card's recorded gap; the live block is skipIf.
  • formula $contains (Q2 = C) and the RLS write check — right; the stop is not met. containsAsksMembership reads the declaration when supplied, else Array.isArray(actual); the declaration is threaded through $and / $or / $not. security-plugin.ts:3275-3278 evaluates check on the raw post-image with writeCheckFieldOptions, which is declaredComparisonColumns handing { type, multiple } for every declared field. The six-row table stands: no write path admits a row the same policy's read hides; scalar 'x' is fail-closed and routed to [Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109's family (ruled A). formula depends on @objectstack/spec and cel-js alone, so it cannot import core.
  • The three value-level copies — still a declared duplication, not a wrong answer. Re-measured at this head: JSON_NUMBER_TEXT is byte-identical at formula/matches-filter.ts:1104, objectql/having-filter.ts:1673 and driver-memory/memory-driver.ts:347, and identical to the inline regex in core's jsonMembershipCandidates (json-membership-sql.ts:106), with the same true / false / null text rule (:98). fix(driver-memory)!: refuse the equality and ordering family on a declared JSON-stored field, in the SQL family's words (#21066) #21159 (in the merge) touched driver-memory but not that constant.
  • spec filter.zod.ts — comments only, and truthful. Every changed line in all four hunks (@@ -428, -728, -747, -1022) is a * docblock line: three calendarDay*Rewrite pointers now name lowerFilterCondition at the seams (true since ceee88f46), and the $contains implementation-status list gains driver-mongodb and formula, each entry stating what this diff does and that mongod was not measured. No schema, type or export moves.
  • engine.ts delete-probe docblock — comment only, truthful, unchanged from the previous head; main's hasObjectMiddleware hunk is some 10,500 lines away from it.
  • Docs sentence (query-syntax.mdx) and the seam test's titles and header — unchanged blobs, true at this head as judged before.
  • F7 (lteBound) untouched — matches-filter.ts:688, :693 (the call sites) and :902 (the function) at this head; [Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109 remains open.

② Semver level

  • The FAIL item is cleared. The changeset frontmatter now lists '@objectstack/spec': patch beside objectql, driver-mongodb and formula, and its spec bullet states what publishes truthfully: docblock only, in the shipped src/data/filter.zod.ts (packages/spec/package.json files includes src/**/*.zod.ts), the three pointer rewrites and the two implementation-status entries, "No schema, type or export changes." That matches the diff hunk for hunk. patch is the right floor for a shipped-source comment edit, the grade ACCEPT 5915623622 set for this class on this card.
  • Clause-②: no — unchanged in the PR body (line 2) and the changeset, and still right. The spec addition changes no surface; nothing in ① adds or removes an export, type member, authorable key or refusal class. The RLS admit-set move is policy evaluation over data, declared cell by cell, toward the ruled contract, with the one counter-contract cell fail-closed and routed. Not yes, not (narrowing).
  • The other three patch levels — unchanged and still right: objectql (a direct door compares as written, item 5, F6's and F3's grade), driver-mongodb and formula (answers aligned to the declared contract; signatures unchanged). Tests do not ship (files is dist, README, CHANGELOG in each; **/*.test.ts is outside the programs), and content/docs is no package.
  • Check Changeset is success on both events, the 16:08 run reading the edited body's clause line against the four levels.
  • PR body: the Levels sentence (body line 60) now reads: the four packages are patch, the spec entry is docblock-only because the file ships, the docs and test edits do not publish. The first two lines are unchanged (Part of #20822, Clause-②: no). The stored body is 12,109 bytes against 11,940 at creation (report 5934956420), one update event (updated_at 2026-10-01T16:08:02Z, matching the 16:08 check re-runs), and the patch report 5935438556 reads back "only the Levels sentence changed, 12109 sent, 12109 stored". The body's edit history is not readable from this session (GraphQL is refused here, and REST has no body-edit endpoint), so "only" rests on that read-back plus the +169-byte delta and the unchanged first two lines, not on an independent line diff. Nothing in the body contradicts the diff.
  • Cosmetic, not blocking: the changeset's summary-line scope fix(objectql,driver-mongodb,formula) and the PR title omit spec; the frontmatter carries the level, and the squash subject is the seat's to set at landing.

③ Boundary flags

open_questions is [] in both reports (5934956420, 5935438556); out_of_scope_findings is [] in the patch report, which lists no deviations and retracts the "comments fast lane" claim. The 13 items of 5935291820 carry over; those the seat's decision 5935317796 disposed, and this round's own items, each answered or escalated:

  1. (4) The three candidate-rule copies — the seat sets carrier none (Acceptance notes), a shared home in @objectstack/spec/data deferred to the release window. Answered as a seat decision: at this head the copies are byte-identical and pinned on both sides (①), so no answer is wrong; the duplication stays named in three docblocks and in the PR's Acceptance notes.
  2. (9) compileScopedFilterToSql with no declarations reads no column as datetime — carrier named: the domain:services lane at its next claim on the RLS compile seam ([Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109's lane). Escalation satisfied.
  3. (11) filter.zod.ts's pre-existing "driver-turso inherits it" line beside the still-substring Turso remote $contains — carrier [finding] driver-turso remote: RemoteTransport.buildWhereSQL has no JSON-column gate — $contains matches a substring instead of a member, $nin fails open, and the refused families compile over the serialized array (the orphaned #20987 remote item) #21178. Not this PR's edit.
  4. (12) Open-tail reminder — the group-1 matcher-pointer carry (ACCEPT 5915623622) must be named on the F7 PR's claim, the card's last PR. This PR is not the last; nothing breached here.
  5. The merge of main — judged above: nothing carried into the PR's hunks; the patch report's "an additive engine.ts hunk and a spec contracts hunk; neither touches this PR's lines" is confirmed on the ref.
  6. The PR body edit — made through the seat's post-stamped --body relay with --ack-through=5935291820 (patch report api_writes), the body-edit path the dev contract assigns; consistent with the 16:08 check re-runs.
  7. Real mongod NOT MEASURED — the card's recorded gap; the live block is opt-in; Test Core is in progress.
  8. 14 checks in progress — not verdicts, listed above; Lint & Repo Gates (check:generated, the full lint), Test Core, Build Core, Type Check · consumer gates and · workspace, Temporal Conformance and the Dogfood gates land after this record.

Implemented-by: claude/issue-20822-g3b-having-contains-tail
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS


Generated by Claude Code

Merged via the queue into main with commit e18fea6 Oct 1, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20822-g3b-having-contains-tail branch October 1, 2026 17:01
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 protocol:data size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants