Skip to content

fix(formula,plugin-security)!: delete formula's whole-day copy of the bare-day bound (lteBound); the RLS seam refuses a non-number on a numeric column - #21336

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21242-retire-ltebound
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21242-retire-ltebound

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21242

Clause-②: no (narrowing)

This deletes @objectstack/formula's own copy of the whole-day upper bound (lteBound in matches-filter.ts). Ruling 5933322270 (letter A, on #21109) decided the deletion once the RLS write check judged the stored form (PR #21235). Its precondition, #21255, landed as c2cd65154, as the seat's answer B (5942310644) sequenced. The deletion opened one fail-closed write/read split, a number column against a day string. The engine's where door already refuses that comparand, so the RLS compile seam in plugin-security now runs the same spec verdict. That surface was declared on the services seat post (5945507305) and in claim amendment 5945512244 before the edit.

Base 5f7d84769a (the fast-forward of the branch to origin/main). Head f6a9373da4 (the contract review 5946309118 judged be721ef6fb; the review round added dd75c24d11 and f6a9373da4, see below).

#20822 is closed by the seat at landing. This PR does not close it.

What changes

  • packages/formula/src/matches-filter.ts. lteBound, its docblock, the addWholeDays bullet that named it, and the nextUtcCalendarDay / isUnboundedAbove imports are deleted. $lte and a $between maximum now compare as written, with the same order() every other ordering operator uses. A docblock on the $lte arm records where the whole day is applied now and gives the remedy for a caller that passes no seam: lowerFilterCondition(filter, { isDatetimeColumn }).
  • packages/plugins/plugin-security/src/rls-compiler.ts.
    • RlsFieldGuard gains number: each declared column's type, and a formula's returnType.
    • judgeCompiledComparands runs narrowPolicyNumberComparands after the shape door and before the comparand-type door, the engine's where order.
    • The walk only routes the spec's answers: numberComparandFieldVerdict / numberComparandDoorVerdict decide, NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS / NUMBER_COMPARAND_DOOR_LIST_OPERATORS name the positions, and numberComparandRefusalMessage gives the words. No rule is restated.
    • A refusal leaves through the existing refused-comparand route: the read gets the deny sentinel and the write a 403. A numeric string is narrowed copy-on-write, and the provenance mark is carried.
  • packages/plugins/plugin-security/src/security-plugin.ts. numberComparandFieldsCache is filled in the same loadObjectFieldNames pass as the datetime cache and cleared with it. Both guard construction sites pass it.
  • Tests and changesets. The formula direct-call cases move (H5 below). The objectql aggregate pins are updated and extended, and plugin-security gains a new pin file. There are two changesets: formula and plugin-security, each minor, BREAKING, Clause-②: no (narrowing).

Caller table (H1), re-run at 5f7d84769a

lteBound was at matches-filter.ts:902, $lte used it at :688 and $between at :693, as H1 said. The three commits between c2cd65154 and the base touch none of these files. There are 4 production call sites in 2 packages (objectql, plugin-security), not 3: formula is the definer. Every other non-test hit is a re-export, a type or prose.

package call site (head) does a non-storage bound reach it? evidence
objectql having-filter.ts:1657 compareWithReference (having and aggregations[i].filter, every { $field } comparison) yes: a per-row referent holding a bare day. No seam can lower a referent, and literal bounds are lowered by the engine seam before the walker probes A1-A6 below
plugin-security rls-check-stored-form.ts:316 storedFormCheckJudge (the RLS write check) yes, on columns that are not datetime. The compile seam lowers datetime only, and the stored-form step covers date / datetime / time R1-R4 below
plugin-security explain-engine.ts:972 matchUnderDeclaredColumns (the explain row verdict) yes: the same compiled using filter, on stored rows the explain column of R1-R4
plugin-security security-plugin.ts:3622 (:3611 at base) clearsWall (the tenant wall) no. tenantLayer0FilterOf (tenant-layer.ts:173-179) emits only { organization_id: X }, { organization_id: { $in } } or the deny-id equality source read

The two registry-less pins (H2)

H2 said the deletion moves both pins by design. Measured, it moves neither as written: no row of their fixtures closes on its due day, so the as-written comparison gives the same answer. Each pin is kept, and each is extended with one boundary row so that it now records the move. The reverse check reddens both.

pin before after why
engine-aggregate-filter.test.ts, registry-less closed_at $lte { $field: 'due_on' } over ORDERS picked: 3 picked: 3 o1, o2 and o5 close days before their due day, so both readings count them
…the same, with o7 closed on its due day at 15:00 (added) picked: 4 of 7 picked: 3 of 7 as written, '2026-01-05T15:00:00.000Z' sorts above '2026-01-05', which is ADR-0053 D-D1 item 5 for a caller that passes no seam
engine-aggregate-having-comparand-shape.test.ts, registry-less last_closed $lte { $field: 'first_due' } over DT_ROWS ['c1'] ['c1'] no group closes on its first due day
…the same, with group c4 closed on its due day at 15:00 (added) ['c1', 'c4'] ['c1'] the same reason

Residual rows (H3, #21299) and one more mainline row

Measured through engine.aggregate, which REST POST /api/v1/data/:object/query calls, on both driver doors, with the before and after formula dists swapped in turn. Every changed answer is pinned.

row door before after the where twin
audit-opt-out object (systemFields: { audit: false }): row-carried created_at / updated_at $lte { $field: 'due_on' }, per-aggregation filter engine.aggregate 3 of 4 1 of 4 refused 400 on an audit-on object, both at this position (INVALID_FILTER / 400) and in SqlDriver's where (measured on an audit-on object; the audit-opt-out where was not driven)
…the same pair at having engine.aggregate unreachable: max(created_at) on an undeclared field is INVALID_FIELD / 400 the same (n/a)
direct applyInMemoryAggregation, closed_at $lte { $field: 'due_on' }, with or without a field map the published @objectstack/objectql export 3 of 4 1 of 4 (no seam; the class rule belongs to engine.aggregate)
not anticipated: two declared text columns of one class, code $lte { $field: 'label' } with '2026-01-05 noon' against '2026-01-05' engine.aggregate per-aggregation filter, and having over two groupBy columns counted / kept not counted / dropped SqlDriver where keeps b2 only; SqlDriver engine.aggregate went from picked: 2 to 1, now matching the twin

The last row moves toward where. It is the aggregate twin of the RLS text-to-text cell (R3), and it is pinned in both test files.

The RLS write check (H4)

Measured through ObjectQL.insert + SecurityPlugin + SqlDriver (better-sqlite3), as a member, with the policy's using equal to its check:

cell before (base) after the deletion after the seam step (head)
R1 title text $lte '2026-01-05', '2026-01-05T15:00:00Z' admitted / read hidden / explain visible 403 / hidden / hidden 403 / hidden / hidden
R2 the same, '2026-01-05 noon' admitted / hidden / visible 403 / hidden / hidden 403 / hidden / hidden
R3 title $lte { $field: 'code' }, code = '2026-01-05' admitted / hidden / visible 403 / hidden / hidden 403 / hidden / hidden
R4 amount number $lte '9999-12-31', 5 admitted / shown / visible 403 / shown / hidden 403 / hidden / hidden; policy dropped refused-comparand on both clauses
controls: date / datetime on the boundary day, datetime $lte '9999-12-31', text earlier day, number vs number, date vs date reference, text vs '9999-12-31' all admitted / shown / visible unchanged unchanged

The where door question, measured: engine.find with where { amount: { $lte: '9999-12-31' } } returns INVALID_FILTER / 400 ("compares a declared number field against "9999-12-31" … which is not a number"). The same holds for 'abc', while '10' is narrowed (1 row). So the seam refuses the same comparand, per answer B.

Direct-call cases (H5)

H5 said "the storage-form lowering" means @objectstack/core's temporalStorageForm. Measured: formula depends on @objectstack/spec alone (package.json), so its tests cannot reach core without a new dependency edge, a vitest alias and a lockfile change. The lowering the seams apply to a bound is spec's lowerFilterCondition (ADR-0053 D-D1 item 4), and temporalStorageForm is what the write check applies to the image, pinned in rls-check-stored-form.test.ts (green). So the cases move to the seam's lowering, or to storage-form literal inputs. None is deleted:

  • matches-filter.test.ts, calendar-day block. These 8 assertions changed answer: the instants at 00:00, 09:15 and 23:59:59.999 under $lte '2026-07-28', $between at 21:40, the last day at 23:59:59.999, a Date at 10:00, the same-day $between, and '9999-12-30T10:00Z'. Each is now asserted as written (false), and its whole-day answer is asserted through lowerFilterCondition(…, { isDatetimeColumn }) (true), together with the lowered shape ($lt the next day; { $null: false } on the last day). The other assertions are kept, and run through the lowering where that is meaningful. One case is new: a number against a day string is false.
  • matches-filter-field-reference-offset.test.ts. "A calendar day stays a calendar day" moves to the stored date form (done: '2026-03-02'). The old instant-against-day pair is now pinned as written (false at addDays: 1, true at 2), and handed the declared columns it is refused INVALID_FILTER / 400.
  • matches-filter-temporal-conformance.test.ts. Every TEMPORAL_CASES filter runs through lowerFilterCondition with the declared { at: datetime, on: date } map, the RLS compile seam's input, as the driver-mongodb and driver-memory conformance suites already do. The expected rows are unchanged.

Filter-semantics faces (compile-surfaces.md)

  • 1 driver-sql: already conformant. It compiles a bare-day bound as written on a column that is not datetime. Measured: where { title: { $lte: '2026-01-05' } } hides the instant text, and title $lte { $field: 'code' } keeps b2 only. There is no whole-day arm in sql-driver.ts (one comment hit).
  • 2 turso RemoteTransport: already conformant by source (0 nextUtcCalendarDay hits in remote-transport.ts). The transport tests that import the matcher pass (103).
  • 3 analytics read-scope-sql: not touched; 0 hits in read-scope-sql.ts. read-scope-sql-conformance (which imports the matcher) is green.
  • 4 analytics filter-normalizer: out of scope. That face's interim copies (native-sql-strategy.ts:1761, objectql-strategy.ts:566, preview-evaluator.ts:106) are removed by its own deletion (ADR-0053 D-D1 item 9), not by this card. native-sql-filter-logic-conformance is green.
  • 5 formula: changed (this PR).
  • Half-face objectql having-filter: answers changed, source untouched. { $field } pairs that no seam lowers are compared as written, and both aggregate test files pin the change.
  • Unfrozen driver-memory / driver-mongodb: already conformant. Their filter compilers carry no copy (0 hits in memory-matcher.ts / mongodb-filter.ts). The cube face's dateRange window (memory-analytics.ts:1236) is D-D1 item 8 and out of scope.

Review round (head f6a9373da4, after contract review PASS 5946309118)

Section added by the seat (domain:engine#1), from the dev's review-round report:

  • The plugin-security changeset. Two sentences are corrected:
    • one published type gains a member: RlsFieldGuard, the optional fieldGuard argument of the root-exported RLSCompiler.compileFilter, gains the optional number member. It is additive and not an accept-set change;
    • the read leg is stated as measured on SQLite only.
  • The WARN detail names its clause, and names PostgreSQL's server error only for using. Both clauses are pinned.
  • An end-to-end text-column cell at the plugin-security door: a bare-day $lte on a text column now refuses the write (403) and hides the row on read, with the controls admitted. It goes red with the old formula dist swapped in.
  • The design doc's F7 row reads retired. No other row moved.
  • check:dispatcher-error-vocabulary (red on dd75c24d11) is fixed by spelling the one call site's code and status as literals (INVALID_FILTER, 400), the gate's preferred remedy.
  • Verification at f6a9373da4: the plugin-security full suite passes (3,423); the whole Lint & Repo Gates job, run locally from lint.yml, is 185 of 185 steps green; dispatch-gates is 67 derived, 67 run, 0 not measured.

Verification (head be721ef6fb, through scripts/pm/os-verify-lock.sh)

  • Build. turbo run build --filter='@objectstack/dogfood^...' (63 of 63) on the base tree. After the change, formula and plugin-security were rebuilt. The formula dist has 0 lteBound hits (3 before).
  • Full suites:
    • @objectstack/formula: 43 files, 1257 passed;
    • @objectstack/plugin-security: 158 files, 3418 passed and 23 skipped, including PR fix(plugin-security): the RLS write check judges a date / datetime / time column in its stored form #21235's rls-check-stored-form pins;
    • @objectstack/objectql: test 360 files, 7113 passed, and test:repo 1 file, 5 passed;
    • @objectstack/dogfood, against the rebuilt formula / plugin-security dists (0 lteBound hits in the formula dist, 4 narrowPolicyNumberComparands hits in the plugin-security dist): 164 files passed and 1 skipped, 1354 tests passed and 3 skipped, exit 0.
  • Typecheck formula, plugin-security and objectql: exit 0, check:test-typecheck OK.
  • Test-only consumers of the matcher:
    • driver-sql: 8 files, 481 passed and 4 skipped;
    • driver-sqlite-wasm: 6 files, 217 passed;
    • driver-turso: 2 files, 103 passed;
    • driver-memory: 18 passed;
    • driver-mongodb: 37 skipped (no mongod here);
    • platform-objects: 18 passed;
    • service-analytics: 2 files, 72 passed;
    • rest aggregation-filter-where-doors: 20 passed.
  • Gates. dispatch-gates --commands derived 66 commands. All 66 were run and all exited 0. dispatch-gates --ran reports "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN". Two gates first hit my own 280 s wrapper timeout (check:query-options-erasure, check:type-check-debt) and were re-run to exit 0. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) because 8 unrelated packages had no dist/. They were built (41 of 41 tasks from cache), and the gate then exited 0. Lint is narrowed, not the full pnpm lint. The checked population is the 9 .ts files of the diff, linted with eslint --no-inline-config --format json: 9 files, 0 errors, 0 warnings. eslint.config.mjs enables no type-aware linting (no parserOptions.project, header at :327) and no import plugin, so this diff cannot move the verdict on any file it does not touch. The full pnpm lint is left to CI.
  • Reverse check 1, the seam. ablation-replace turned the number door off at its one call site (judgeCompiledComparands(…, fieldGuard?.number) without the third argument), on-disk anchor 1 to 0. rls-number-comparand-door.test.ts then went 4 failed / 7 passed: the three refusal pins and the == '10' narrowing pin. The restore was proven by blob equal to HEAD and an empty git diff HEAD.
  • Reverse check 2, the deletion.
    • The whole-day reading was put back into $lte with a 'ablation-21242' marker (on disk 1; anchor 1 to 0). The formula suites went 9 failed. 4 of those are the as-written pins: the bare-day block, the last-day as-written case, the 9999-12-30 control's as-written leg, and the instant-against-shifted-day pair. The other 5 come from the ablation's simpler day arithmetic on an impossible day and on 9999-12-31 (two temporal-conformance date cases, the impossible-day case, and one leg each of the seam and no-instant cases), which lteBound handled with its calendar check and UNBOUNDED_ABOVE.
    • Rebuilt, ablation-dist-preflight found the marker in dist/. The objectql pins went 6 failed of 286: both registry-less boundary pins, the text/text, audit-opt-out and applyInMemoryAggregation pins, and the having text/text pin.
    • The restore leg was proven: --restore, a rebuild, --absent preflight exit 0, blob f963059621 equal to HEAD, and an empty git diff HEAD.

Acceptance notes (out of the claim's surface; nothing filed)

  • plugin-security/src/rls-check-stored-form.test.ts:32-41, :233. The header "formula's whole-day copy is out of reach here" and the withoutWholeDayCopy matcher wrapper are now redundant: the wrapper changes no answer at head, and the pins stay green. Carrier: the services seat when it next touches the file (otherwise 承接者:无).
  • docs/design/predicate-compilation-convergence.md:361: the F7 row still lists lteBound (2 sites). Stale prose, 承接者:无.
  • driver-sql/src/cross-field-conformance-cases.ts:145: the note names "memory lteBound". It was already stale before this PR, 承接者:无.
  • The RLS seam's number-refusal WARN detail is written for the clause it refused: the path is rooted at the clause (check.amount.$lte / using.amount.$lte), and it names PostgreSQL's server error only for using, the clause a driver binds; check is evaluated in-process. Both clauses' wording is pinned in rls-number-comparand-door.test.ts.
  • The plugin-security changeset names one facet that admits more: after narrowing, record.amount == '10' admits a write of 10. The check used to refuse that write, compared as text, while the read showed the row. That facet is read/write agreement, and it ships under the no (narrowing) line.

Generated by Claude Code

claude added 2 commits October 2, 2026 04:05
… as written; its whole-day copy (lteBound) is deleted

The RLS compile seam and the engine's aggregate seam lower a bare-day $lte /
$between with the shared lowerFilterCondition, and the RLS write check judges
a declared date column in its stored form, so this face keeps no copy of the
rule (ADR-0053 D-D1 items 5 and 9). The direct-call cases move to the seam's
lowering or to stored-form inputs.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
… numeric column, as where does; pin the moved aggregate answers

The compiled policy filter runs the spec's number-comparand verdict, the one
the engine's where door consults: a comparand a numeric column cannot be
compared with drops the policy through the refused-comparand route (read deny
sentinel, write 403), and a numeric string is narrowed to its number. The
objectql pins record what formula's deleted whole-day copy moves at the
registry-less, audit-opt-out, text/text and direct applyInMemoryAggregation
positions. Changesets for formula and plugin-security.

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

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/formula, @objectstack/plugin-security, touching 21 documentable anchor(s).

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

  • content/docs/api/data-api.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in judgedNumberComparand))
  • content/docs/api/error-catalog.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in judgedNumberComparand))
  • content/docs/deployment/validating-metadata.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in judgedNumberComparand))
  • content/docs/kernel/contracts/data-engine.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in judgedNumberComparand))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/permissions-matrix.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/sharing-rules.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in judgedNumberComparand))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in judgedNumberComparand))
  • content/docs/releases/v17/17-4.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in judgedNumberComparand))
  • content/docs/releases/v17/17-5.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in judgedNumberComparand))
  • content/docs/releases/v17/17-6.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in judgedNumberComparand))

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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 23365eaedf5bd07795b3c3a47ff6f113469e5432 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 23365eaedf5bd07795b3c3a47ff6f113469e5432

⚠️ 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 23365eaedf5bd07795b3c3a47ff6f113469e5432 → 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: be721ef6fbed24fca447774a5e13e1ae95eb7cf6
Local-runs: none

Read-only and adversarial, on PR #21336 at the head above; merge base 5f7d84769a, two commits (e4d85c50b0, be721ef6fb); the API diff and git diff 5f7d8476..be721ef6 agree hunk for hunk (11 files, +737 / −96). Inputs: card #21242 (body and all ten comments), ruling 5933322270 on #21109 (letter A), the services declaration 5945507305 on #21118, the #21299 pointer 5944816815, the PR body, file list and diff, and the head's check-runs, read last. One caution for the seat: the shared checkout's FETCH_HEAD was moved by another fetch between my fetch and my first grep, so every reading below was re-taken against the literal sha.

① Derived judgments

  1. The deletion — right. At the head packages/formula/src/matches-filter.ts has no lteBound: the function, its docblock, the addWholeDays bullet naming it, and the nextUtcCalendarDay / isUnboundedAbove imports are gone; $lte and the $between maximum use the same order() the other ordering arms use, with an inclusive at-most test; the 9999-12-31 no-bound special case went with it and now lives only in lowerFilterCondition (a lone $lte on that day lowers to { $null: false }, pinned). No replacement copy: repo-wide lteBound hits at the head are the F11 preview copy in service-analytics/src/preview-evaluator.ts (another face, ADR-0053 D-D1 item 9, its own deletion card, untouched), two rows of docs/design/predicate-compilation-convergence.md, the stale driver-sql conformance note and one service-analytics test docblock — all pre-existing. This is the ruling's "deleted mechanically".

  2. The caller table — right, re-derived. git grep matchesFilterCondition at the head, tests, dist and prose excluded: 4 production call sites in 2 packages — objectql/src/having-filter.ts:1657, plugin-security/src/rls-check-stored-form.ts:316, plugin-security/src/explain-engine.ts:972, plugin-security/src/security-plugin.ts:3622; formula/src/index.ts:118 is the re-export and matches-filter.ts:331 the definer, which is what the first count's "3 packages" counted. Tenant wall: tenantLayer0FilterOf (tenant-layer.ts:170-180) emits only organization_id equality, $in or the deny filter, so no bound reaches it — unchanged by construction. Explain: explain-engine.ts compiles nothing itself (it imports RLS_DENY_FILTER and compiledPolicyNameOf only); it judges the using filter the plugin compiled at the guard site, so its row verdict moves with the read. A non-storage bound still reaches three callers (a per-row { $field } referent at having-filter; non-datetime columns at the write check and at explain); each changed answer is recorded in the PR body and the changesets and pinned at the evaluator (formula) and at engine.aggregate (objectql). The write-check cells on text columns (R1–R3) and explain's verdicts are recorded, not pinned end to end in plugin-security — see ③.

  3. The objectql pins — right. H2 falsified as stated: no ORDERS row and no DT_ROWS group closes on its due day, so both registry-less pins keep picked: 3 and ['c1']; each is kept and extended with one boundary row (o7 / c4, closed at 15:00 on the due day) whose answer records the move (4 of 7 becomes 3 of 7; ['c1', 'c4'] becomes ['c1']). The residual rows of objectql having and the per-aggregation filter answer a { $field } comparison against a no-class column (file, multi-valued, formula) where the where twin refuses it 400: the family's close-out card, with a per-position enumeration pin #21299 are pinned: audit-opt-out created_at / updated_at against due_on at the per-aggregation filter (3 of 4 becomes 1 of 4, both drivers, the audit-on object refused INVALID_FILTER / 400 as the control) and direct applyInMemoryAggregation (3 of 4 becomes 1 of 4, with and without a field map). The unanticipated mainline row — two declared text columns of one class, code $lte { $field: 'label' } with '2026-01-05 noon' against '2026-01-05' — is a correct change at a public door: one class, so the objectql having and the per-aggregation filter judge a cross-field reference's comparison class only on an addDays pair, so a plain datetime-vs-date { $field } answers by formula's whole-day reading where the where twin refuses it 400 #21255 rule does not refuse it; a per-row referent, so no seam lowers it; the old count read a text label as a calendar day, and the new one is the where twin's (driver-sql keeps b2 only). It is pinned at the per-aggregation filter (2 of 4) and at having over two groupBy columns (['2026-01-04']), and it is named in the formula changeset's engine.aggregate list. No objectql changeset is owed: objectql source is untouched.

  4. The formula test moves — right; nothing deleted or weakened. The 8 assertions whose answer changed (the three instants under $lte '2026-07-28', the $between at 21:40, 9999-12-31T23:59:59.999Z, the Date at 10:00 on that day, the same-day $between on 9999-12-31, and 9999-12-30T10:00Z) each keep a whole-day leg through lowerFilterCondition(…, { isDatetimeColumn }) and gain an as-written leg; the lowered shapes are pinned ($lt the next day, { $null: false }, the split $between). Every unchanged assertion is kept (the next-day stop, the plain calendar day, the full-ISO bound, $gte / $gt / $lt, the impossible day, the null bound, the no-instant text value), and one case is new (a number against a day string is false). matches-filter-field-reference-offset.test.ts: the calendar-day case moves to a stored date input with both assertions intact; the old instant-against-day input is re-pinned as written (false at addDays: 1, true at 2) and refused INVALID_FILTER / 400 once the columns are declared (MatchesFilterOptions.fields is the right shape). matches-filter-temporal-conformance.test.ts: every TEMPORAL_CASES filter runs through the lowering with { at: datetime, on: date }, expected rows unchanged. One leg (the 9999-12-30 control's negative) now runs through the seam only; as written it is false too, so nothing is weakened.

  5. The RLS compile seam — right, and one rule. Guard: RlsFieldGuard.number, filled by noteNumberMeta in loadObjectFieldNames on both fields shapes, set beside datetimeFieldNamesCache (security-plugin.ts:9315-9316) and cleared beside it in the metadata watch (:1433); fieldNamesCache has one setter (:9227) in the same pass, so declared and number cannot diverge, and both guard sites (:7463, :7735) pass it. Verdict imported, not copied: numberComparandFieldVerdict, numberComparandDoorVerdict, numberComparandRefusalMessage, NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS and NUMBER_COMPARAND_DOOR_LIST_OPERATORS from @objectstack/spec/data — the same five names objectql/src/number-comparand-declared-type-door.ts imports; no grammar is restated. Order: assertListComparandShapes, then narrowPolicyNumberComparands, then normalizeFilterComparandTypes, then lowerFilterCondition — the engine's relative order (engine.ts:1027, :1075, :1085, :1251). Refusal: the thrown INVALID_FILTER / 400 is caught by the existing catch and returned as refused-comparand, so the policy is dropped for the clause being compiled; the new pin file records check:refused-comparand and using:refused-comparand on each of three predicates, 403 on the write and the deny sentinel on the read. Numeric string: narrows replaces the comparand copy-on-write (out ??= { ...spec }), provenance carried by withProvenanceOf. Guard absent: nothing judged, the shape datetime already takes. The walk itself is the seam's own: objectql's narrowNumberComparands is not a package export and objectql is a devDependency of plugin-security, so a shared walker cannot be imported; the positions walked are the spec's operator lists, and $field references and dotted keys are skipped as the engine skips them. PR fix(plugin-security): the RLS write check judges a date / datetime / time column in its stored form #21235's pins: rls-check-stored-form.ts and its test are byte-identical between base and head; their verdict is the Test Core rollup (③).

  6. The four RLS cells — right. R1 (text under a bare-day $lte, written as an instant), R2 ('2026-01-05 noon'), R3 (text against a text referent holding a bare day): write admitted becomes 403, read hidden throughout, explain visible becomes hidden. R4 (number under $lte '9999-12-31'): the deletion alone opened write 403 / read shown; with the seam, write 403 / read hidden / explain hidden, refused-comparand on both clauses. Every cell now gives one answer on read and write. Pinned: R4 (three refusal predicates), the numeric-string cells ('10' under $lte and == at 5, 50, 10 and 11), the controls (a numeric literal; a text column compared as written). The declared-column controls (date / datetime on the boundary day, datetime $lte '9999-12-31') are unchanged, as the stored-form check and the datetime lowering carry them.

② Semver level

③ Boundary flags

Implemented-by: claude/issue-21242-retire-ltebound
Reviewed-by: session_017xfMoEjKUuSh2xYB8sCozp

VERDICT: PASS


Generated by Claude Code

claude added 2 commits October 2, 2026 05:51
… review fixes for #21242

The WARN detail of a number-comparand refusal at the RLS compile seam is
written for the clause it refused: the path is rooted at the clause, and the
PostgreSQL server-bind sentence appears only for `using`, the clause a driver
binds; `check` is evaluated in-process. Pinned for both clauses, plus an
end-to-end text-column write-check cell. The plugin-security changeset names
the additive `RlsFieldGuard.number` member and states the read reading as
measured (SQLite only). The convergence design doc's F7 row is retired.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…ls at its one call site

check:dispatcher-error-vocabulary reads PolicyNumberComparandRefusal as a code
helper (its constructor assigns a parameter to .code), so the codes it stamps
must be readable at the call site: 'INVALID_FILTER' and 400, the literals the
spec's verdict types its refusal with.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 07:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 07:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 7aab759 Oct 2, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21242-retire-ltebound branch October 2, 2026 08:08
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…eference matcher (objectstack-ai#20822 group 4) (objectstack-ai#21395)

Fixes objectstack-ai#20822
Clause-②: no

objectstack-ai#20822 group 4, the card's last group: the comments and docblocks
outside `driver-memory` that still named its retired reference matcher
(`memory-matcher.ts`, retired by commit `8fec76a2b`) as a live surface.
This is the carry group 1's ACCEPT put on the card's last group PR.
Claim: the PM's `Claim:` comment 5948997842 (branch
`claude/issue-20822-retired-matcher-pointers`). Cross-lane declarations:
spec seat post (5949027331) and services seat post (5949038856).

**The seat confirms `Fixes` at ACCEPT.** Hypothesis H1 (the site list is
complete outside `driver-memory`) is falsified: 28 more sites outside
the claim's file surface still name the matcher as live (25 comments and
docblocks, one JSON ledger note, and 2 string literals in code). They
are not edited here. They are listed under "Sites outside the claim's
surface" below, and the route is the open question in the
`os-dev-report` on objectstack-ai#20822.

Base `11905a4f8b`; `origin/main` `db0cf2231b` merged once (merge
`ff241ad71a`, no conflict, no file in this diff). Head `ff241ad71a`. Net
diff against `main`: 10 files, +68 / -35. Not governed.

## What changes

Comment and docblock prose only. A sentence that named the matcher as a
live surface now names what carries the semantics today, measured per
site, or says the matcher is retired. Historical sentences stay.

| Site | Reading at base | Action |
|---|---|---|
| spec `filter-logic-conformance.ts:15` | live: the backend table's
"In-memory matcher / `memory-matcher`" row | now "In-memory query path /
`driver-memory` `normalizeFilterCondition`, then mingo", with the
retirement in the same row (H3) |
| spec `filter-comparand-shape.ts:127` | live: "the matcher's own
answers ... are sealed behind this refusal" | past tense, plus the
retirement |
| spec `filter-comparand-shape.ts:142` and `:144` | live: "compares
through JS coercion", "the matcher is not repaired" | past tense, plus
the retirement |
| service-analytics `objectql-strategy.ts:1687` | live:
"`driver-memory`'s matcher ... pin" `{$not: {}}` | now `driver-memory`'s
query path (`memory-driver-document-not.test.ts` pins it) |
| service-analytics `objectql-strategy.ts:2055` (the unlock read it at
`:2014`) | live: `memory-matcher.ts` "does" read `$regex` | past tense,
until `$regex` and then the matcher were retired |
| service-analytics `filter-normalizer-not-null-safe.test.ts:50` | live:
points at the deleted `memory-matcher-not-null-safe.test.ts` | now
`memory-driver-document-not.test.ts`, which holds its cells |
| service-analytics `objectql-contains-canonical-operator.test.ts:31`,
`:103`-`:110`, `:118`, `:305` | live: the mirror evaluates "the way
`memory-matcher.ts` does", and "`driver-memory`'s `$regex` arm is
deliberate and serves a real producer" | past tense; the producer's move
to `$contains` is pointed at in `filter-refusal.ts`. `:118` and `:305`
are in the same file but not in the unlock's list |
| service-storage `attachment-read-visibility.test.ts:13` | live:
"Mirrors `memory-matcher.ts` and `formula`'s `matches-filter.ts`" | now
mirrors `formula`'s `matches-filter.ts`; the matcher is past tense |
| service-storage `attachment-read-visibility.test.ts:326` | live:
points at the deleted `memory-matcher-or-semantics.test.ts` | now
`memory-driver-filter-logic-conformance.test.ts`, which holds its cases
|
| plugin-security `claim-seed-ownership.ts:91` | live, and wrong before
the retirement: the `id IN (...)` scan attributed to `memory-matcher.ts`
| now mingo's `$in`, which `InMemoryDriver` hands the list to (measured
below) |
| formula `matches-filter-not-null-safe.test.ts:17` | live: points at
the deleted `memory-matcher-not-null-safe.test.ts` | now
`memory-driver-document-not.test.ts` |
| formula `matches-filter-not-null-safe.test.ts:120` | live: the matcher
"answers the opposite" | the query path answers the same as this face
(`memory-driver-document-not.test.ts` pins `['1']`); the matcher
answered the opposite until PR objectstack-ai#13356 and is retired |
| `docs/design/predicate-compilation-convergence.md:44`, `:56`, `:358` |
census rows anchored at `3711e0b763` | past tense plus the retirement,
as PR objectstack-ai#21336 did for the F7 row |

Read and left as they are, because each is already historical or not a
claim about a live matcher: spec `filter-logic-conformance.ts:184`,
`:211` (a measurement table dated by its commits), `:244`, `:480`;
`:492` names `memory-matcher-no-value-negated-operators.test.ts`, which
still exists under that name and holds the live path's cells; `:503` and
`:509` are string literals in the past tense;
`filter-comparand-shape.ts:122`-`:124` (the reason for the 2026-08-31
ruling); formula `matches-filter-icontains.test.ts:91` ("what the
reference matcher was moved onto"); the design doc's `:510` (the D6
decision row) and `:570` (a commit-table row).

## Measurements

**H3, what `driver-memory` evaluates a filter with today.**
`InMemoryDriver.find`, `count`, `updateMany`, `deleteMany` and the
others call `convertToMongoQuery` (`memory-driver.ts:1421`). It runs
`assertFilterConditionShape` (`filter-refusal.ts`), then
`normalizeFilterCondition` (`memory-driver.ts:1600`), and hands the
result to mingo's `Query`.
`memory-driver-filter-logic-conformance.test.ts` runs
`FILTER_LOGIC_CASES` through `InMemoryDriver.find`, and
`check:driver-conformance` holds it. So the spec table's in-memory row
names the query path.

**`claim-seed-ownership.ts`'s `id IN (...)` sentence.**
`normalizeFieldOperators`' `$in` arm (`memory-driver.ts:1862`) passes
`$in` through. mingo 7.2.4's `$in` predicate
(`operators/_predicates.js`) is built once per query and called once per
document; each call runs `intersection([values, list])`
(`util/_internal.js`), which fills a hash map from the whole list. So
the sentence's "linear scan of the id list PER ROW" holds, through
mingo, and the attribution to the matcher was wrong.

**Code-token guard (PR objectstack-ai#21357's two readings), base `11905a4f8b` against
the working tree at head, TypeScript 6.0.3.** Reading 1 is the parser's
leaf nodes from a `forEachChild` walk, so comments are trivia and JSDoc
is never visited; a leaf that is not a token is re-scanned with trivia
skipped. Reading 2 is the token stream from a `getChildren` walk, with
JSDoc nodes skipped. Identifiers and string, template and numeric
literals are compared in full.

- Real run over all 8 touched `.ts` files: 26,645 base tokens (reading
2), **0 files with a token change** (exit 0).
- Comment control ("invents no second one" to "invents NO second one",
`objectql-strategy.ts`): 0 files changed (exit 0).
- Positive control, an identifier (`filterNodeToCondition` to
`filterNodeToConditionX`, `objectql-strategy.ts`): DIFFER on both
readings (exit 1).
- Positive control, a string literal (a `FILTER_LOGIC_CASES` `name`
gains an `X`, `filter-logic-conformance.ts`): DIFFER on both readings
(exit 1).
- Positive control, a numeric literal (`MAX_BULK_PER_ROW_HOOK_ROWS / 2`
to `/ 3`, `claim-seed-ownership.ts`): DIFFER on both readings (exit 1).

Each mutation went through `scripts/ablation-replace.mjs` in wrap mode
(anchor 1 to 0). Each restore was proven: blob equal to `HEAD` and `git
diff HEAD` empty.

**`dist` reach (H4), three legs plus a determinism leg.** The five
packages' `dist` files were hashed after each build. Every build exited
0 and ran under the shared verify lock.

All four legs ran at `3ba971f1b6`; the later commits change no file in
the five packages.

- Leg 1: a turbo build of the five packages and their closure (20 tasks,
all five cache misses).
- Leg 2: the 8 changed files of the five packages back at their base
blobs (8 of 8 proven equal), then each package's own `build`.
- Leg 3: the 8 files restored (8 of 8 equal to their `HEAD` blob, `git
diff HEAD` empty), then the same five builds.
- Leg 4: `@objectstack/spec`'s own `build` again. It equals leg 3 in all
230 files, so that build path is deterministic.

| Package | Changed | Added non-test lines found verbatim in `dist` |
Leg 2 against leg 3 | Changeset |
|---|---|---|---|---|
| `@objectstack/spec` | 2 src files | 1 of 8: the table row, in
`data/index.d.ts` and `data/index.d.mts` | 34 files differ:
`data/index.d.ts` / `.d.mts`, 30 source maps (line offsets), 2
build-input hashes | `patch` |
| `@objectstack/service-analytics` | 1 src + 2 test files | 5 of 5, in
`index.js` / `index.cjs` (one also in `index.d.ts` / `index.d.cts`) | 6
of 6 differ | `patch` |
| `@objectstack/plugin-security` | 1 src file | 0 of 4 | only
`index.js.map` and `index.mjs.map` differ: line offsets, because the
docblock grew by two lines; the maps carry no `sourcesContent` | none |
| `@objectstack/formula` | 1 test file | none | 0 differ | none |
| `@objectstack/service-storage` | 1 test file | none | 0 differ | none
|

`.changeset/20822-retired-matcher-pointers.md` therefore declares
`patch` for `@objectstack/spec` and `@objectstack/service-analytics`,
comment text only, with `Clause-②: no`. Each changeset sentence maps to
a diff line: the spec table row at `filter-logic-conformance.ts:15`, and
the two `ObjectQLStrategy` comments at `objectql-strategy.ts:1687` and
`:2055`.

## Gates and tests (head `ff241ad71a`)

- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `ff241ad71a` (10 paths against
merge base `db0cf2231`) derived 89 commands. All 89 ran, each exit code
captured before any pipe. 87 exited 0 on the first run.
`check:dual-build-cjs-loads` and `check:i18n` exited 3 (PREREQUISITE NOT
MET: unbuilt workspace packages), not a measurement. Both exited 0 after
a whole-workspace build (`turbo run build --filter=!@objectstack/docs`,
72 tasks, VERDICT command-exit 0). `--ran` reports "89 derived, 89 run,
0 NOT-MEASURED, 0 UNRUN" and exits 0.
- **Tests, under the verify lock, `vitest run --maxWorkers=2`:**
  - spec `--project local`: 598 files, 17,529 passed, 1 todo;
  - spec `--project repo`: 48 files, 849 passed;
  - formula: 43 files, 1,257 passed;
  - service-analytics: 167 files, 3,786 passed, 83 skipped;
  - service-storage: 41 files, 629 passed;
  - plugin-security: 159 files, 3,479 passed, 23 skipped.
- **Typecheck:** `pnpm --filter ... typecheck` exits 0 for spec (with
`check:scripts-typecheck` and `check:test-typecheck`), formula,
service-analytics, service-storage and plugin-security. Formula,
service-storage and plugin-security also run `check:test-typecheck`, and
service-analytics' `tsc --listFiles` program holds 164 of its
`__tests__` files, both touched ones included.
- **Lint, as a proven narrowing:** `eslint --no-inline-config --format
json` over the 8 touched `.ts` files plus
`service-analytics/dist/index.js` as a control gives 9 results, 0 errors
and 1 warning: the control's ignore notice. None of the 8 is reported
ignored, and each resolves under `--print-config`. `eslint.config.mjs`
never enables type-aware linting (its lines 327-328 say so), so a
comment edit cannot move the verdict on an untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Bytes:** `pnpm check:nul-bytes` exits 0. A control-byte scan over
the 10 changed files finds none.

## Sites outside the claim's surface (round 0; superseded by patch round
1 below)

Read at base `11905a4f8b` with every spelling: `memory-matcher`,
`reference matcher`, `in-memory matcher`, `memory matcher`, `match()`,
the `memory-matcher-*` test-file names, and "`driver-memory`'s matcher".
`driver-memory`, `CHANGELOG.md` and `content/docs/releases/**` are
excluded. Each of these still names the matcher as a live surface:

- `service-analytics` `read-scope-not-null-safe.test.ts:43`: points at
the deleted `memory-matcher-not-null-safe.test.ts`. It is on group 1's
list and inside the ACCEPT's "service-analytics test docblocks", but not
in the unlock's list or the claim.
- `driver-mongodb` `mongodb-filter.ts:1151` ("it is the oracle both
drivers agree with").
- `driver-turso` `remote-transport-boolean-identity.test.ts:43` and
`remote-transport-not-operator.test.ts:40`.
- `formula` `matches-filter.ts:729` (`asciiCaseInsensitiveContains` is
"the same one `driver-memory`'s matcher ... call[s]").
- `objectql`:
  - `having-filter.ts:23`, `:26` and `:2064`;
  - `having-filter.test.ts:8` and `:60`;
- `number-comparand-declared-type-door.ts:46` ("the memory matcher
compares"; which face it means is ambiguous);
- `validation/record-validator.ts:533` ("five hand-rolled shape tests",
one of them the matcher);
- `tsconfig.test.json:22` and `test-typecheck-debt.json:3` (a JSON
string).
- `plugin-security`:
- `bootstrap-declared-capabilities.test.ts:39` and
`bootstrap-system-capabilities.test.ts:29`;
- `rls-check-stored-form.ts:40`, written after the retirement, so it
probably means the query path.
- `service-analytics` `strategies/filter-normalizer.ts:450` ("the
in-memory matcher ... already held to" the table).
- `spec`:
- `filter.zod.ts:411`, `:940` and `:3111` (live implementation-status
tables), `:1198`, `:1242` (the exported `asciiCaseInsensitiveContains`
docblock) and `:3142` (the `$empty` table);
  - `filter-text-conformance.ts:342` ("both then and now");
- `ui/view.zod.ts:605` ("`match()` runs `assertFilterConditionShape`").
- **String literals**, outside this group's form:
- spec `filter.zod.ts:470`, the author-facing refusal for a `null`
ordering comparand. Measured on the published schema door:
`FieldOperatorsSchema.safeParse({ $gt: null })` answers "... its
reference matcher compares through JS coercion ...", in the present
tense.
- spec `filter-operator-vocabulary.test.ts:86`, an assertion message
that prescribes editing the reference matcher.

The governed
`.claude/skills/pm-dispatch/references/compile-surfaces.md:16` stays on
the seat post's protocol observation, as the ACCEPT placed it. 34 more
hits outside the surface are historical (past tense, dated measurements,
or string literals in the past tense). The `os-dev-report` on objectstack-ai#20822
lists them.

## Patch round 1 (head `54c8e70e88`)

Section added by the `domain:engine#1` seat, from the dev's patch-round
report (5952834398 on objectstack-ai#20822).
- **The seat's answer to round 0's open question:** A, minus the two
string literals (claim amendment 5950842658). The round corrects the 26
comment, docblock and ledger-note sites listed there, under the same
rule.
- `read-scope-not-null-safe.test.ts:43` comes first, in its own commit
`68ca26224f`. It is the site inside group 1's ACCEPT carry that the
seat's unlock had dropped, so **every site that ACCEPT carried is now
corrected, and `Fixes objectstack-ai#20822` stands.**
- The added surfaces are declared on the spec (5950854566) and services
(5950863357) seat posts.
- **Not edited:**
- the two string literals `filter.zod.ts:470` (the author-facing
null-ordering refusal) and `filter-operator-vocabulary.test.ts:86` (an
assertion message). They are objectstack-ai#21397's (spec lane), so this PR stays
comment-only;
- `filter.zod.ts:3106` ("that driver's matcher", generic staging
reasoning with an ambiguous referent);
  - the governed `compile-surfaces.md`.
- **Commits:**
- `1118eebbcd` merges `origin/main` `56238d890d` once, with no conflict.
objectstack-ai#21372's hunks moved no listed line;
- then `68ca26224f`, `1bf9b24f26`, `57d675df10`, and `54c8e70e88` (the
changeset).

  The net diff against `main` is 28 files, +152/−81, not governed.
- **Measured per site:** each rewritten sentence names what carries the
semantics today, or says commit `8fec76a2b` retired the matcher. The
probes behind the sentences that state a behaviour:
  - mingo's string ordering for `filter.zod.ts:411`;
- mingo over the declared-number table for
`number-comparand-declared-type-door.ts:46`;
- `InMemoryDriver`'s null-or-missing match for the two `plugin-security`
bootstrap tests;
  - the callers of `asciiCaseInsensitiveContains`.

  Historical sentences are untouched.
- **Code-token guard** (both readings, base = merge base `56238d890`, so
it covers the whole PR):
  - all 24 touched `.ts` files: **0 files changed**;
- the two JSON files through `ts.parseJsonText`, with
`test-typecheck-debt.json`'s `_note` masked: 0 changed. That `_note` is
the text this round edits by design; an `entries` value control still
DIFFERS under the mask;
  - controls (identifier, string, numeric, and JSON value) each DIFFER.
- **`dist` reach** (three legs; the legs agree byte for byte in 276 of
276 files):

  | package | rewritten lines in `dist` | entry |
  |---|---|---|
| `@objectstack/spec` | 12 of 21: 9 in the filter declaration chunk and
3 in the data bundles; `filter.zod.ts` and `view.zod.ts` also ship as
source | `patch`, extended |
  | `@objectstack/formula` | 3 of 3 | `patch`, added |
  | `@objectstack/objectql` | 3 of 19 | `patch`, added |
| `@objectstack/service-analytics` | its round-0 entry stands; this
round's line reaches only the source maps | unchanged |
  | `driver-mongodb`, `plugin-security` | maps only, or nothing | none |
  | `driver-turso` | test files only | none |

- **Tests and typecheck** at `54c8e70e88`:
- the suites of `spec`, `formula`, `objectql`, `driver-turso`,
`driver-mongodb`, `plugin-security` and `service-analytics` are green.
`driver-mongodb`'s real-mongod suites are not measured here: there is no
`mongod`;
  - every touched package's typecheck exits 0;
  - gates: 95 derived, 95 run, 0 not measured.

## Acceptance notes

- **Build path, not this diff.** Leg 1 (turbo) and leg 3 (each package's
own `build`) are both at the same text. They differ in 14
`@objectstack/spec` declaration files (`api`, `automation`, `contracts`,
`marketplace`, `system` and two `node-executor.zod` chunks, `.d.ts` /
`.d.mts`). Leg 3 equals leg 4 byte for byte, so each path is
deterministic and the rewrite's effect was read on legs 2 and 3, which
share a path. The difference between the paths is not explained here.
- **`main` moved after the merge.** Seven more commits landed after
`db0cf2231b` (to `69a12a0952` when this was written); none touches a
file here. The derivation's only stale input among them is
`scripts/sdui-manifest.record.json`. CI judges the merge ref.
- **Contract review** is owed at tier: the path limb is
`packages/spec/src/**`, non-test. The seat runs it.

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ld } against a column with no comparison class, as where does; applyInMemoryAggregation takes the reference rules (objectstack-ai#21406)

Fixes objectstack-ai#21299

Clause-②: no (narrowing)

**Status: the seat answered the open question with C (5952549615 on
objectstack-ai#21299).** The no-class sentence stays `objectql`-local
(`noClassReason`) beside `crossClassReason`, its precedent. The REST
twin pin asserts that each engine position's reason equals the real
`where` twin's diagnostic, verbatim, and that is the drift guard. The
dispatch's no-third-copy clause is relaxed for this sentence only. Patch
round 1 (`b397060136`) replaced the PENDING docblock with the decision
record. A spec-side home (option A below) is recorded as a candidate for
the spec lane in the seat's hand-over ledger, not filed.

Dispatched by `domain:engine#1` (seat post objectstack-ai#6367), claim 5949513813,
branch `claude/issue-21299-having-no-class-refusal`. Base `db0cf2231b`,
`origin/main` merged at `69a12a0952`, head `b397060136` (patch round 1;
round 0 was `466e632c33`).

## What changes

- **`packages/objectql/src/having-filter.ts`**
- The seam `crossClassReferenceViolation` now acts on the spec verdict's
`no-class` answer as well as `cross-class`. Its reason is `where`'s
sentence for the same pair: the referenced column first, then the
target, as `driver-sql`'s `applyCrossFieldComparison` asks them. No
second rule: both answers come from the one
`crossFieldComparisonVerdict` call.
- Both callers ask it of every reference. With `addDays`, only the
`no-class` answer is taken from the seam (`where` asks it before it
reads an offset); the cross-class half of an `addDays` pair stays
`offsetPairViolation`'s, unchanged.
- `assertAggregationFilterReferencesAreDeclared` is exported
(module-level only; neither package entry re-exports
`having-filter.ts`). `AggregationFilterDeclaration.object` becomes
optional, so a caller with no object name gets a diagnostic that names
the field alone.
- **`packages/objectql/src/in-memory-aggregation.ts`**:
`applyInMemoryAggregation`, when handed `fields`, runs
`assertAggregationFilterReferencesAreDeclared` on each per-aggregation
filter before the JSON-column rule, before any row. That is the same
function and the same order `engine.aggregate` uses. The signature is
unchanged. Without `fields`, nothing new is judged.
- **Pins**
-
`packages/objectql/src/engine-aggregate-reference-verdict-positions.test.ts`
(new): the enumeration pin and the card's shapes at the engine door.
- `packages/objectql/src/engine-aggregate-filter.test.ts`: objectstack-ai#21242's
direct `applyInMemoryAggregation` pin is split in two. With no field map
it still answers 1 of 4. With a field map the cross-class pair is now
refused, which is the ruled direction ("one door, whichever caller
enters it").
- `packages/rest/src/aggregation-filter-where-doors.test.ts`: the
positions × verdicts parity table against the real `where` twin on
`SqlDriver`. The reasons are read from the twin's own withheld
diagnostic, never retyped. `boot()` now takes an optional object and
rows.
- **`.changeset/21299-having-no-class-reference.md`**:
`@objectstack/objectql` `minor`, BREAKING, `Clause-②: no (narrowing)`,
`adr-0087: not-required (no-migration-prescription)`.

## Baseline and after (H1), through `engine.aggregate` on `SqlDriver` /
better-sqlite3

Probe fixture: 6 rows, `photo` image, `tags` multiselect, `due_f`
formula (`returnType: date`). Base `db0cf2231b`; after = `be8d2c428b`
(built dists).

| shape | base: populated | base: empty | after (both) | `where` twin
(base and after) |
|---|---|---|---|---|
| per-aggregation `{ customer_id: { $ne: { $field: 'photo' } } }` (text
vs image) | counted 6 of 6 | 0 | `INVALID_FILTER` / 400 | 400, `"photo"
(type "image") has no scalar stored column a comparison can read.` |
| per-aggregation `{ amount: { $ne: { $field: 'tags' } } }` (number vs
multiselect) | 400 from the per-row array floor (row-dependent; 6 of 6
when `tags` is null) | 0 | `INVALID_FILTER` / 400 | 400, `"tags" (type
"multiselect") ...` |
| `having: { photo: { $ne: { $field: 'n' } } }` on an image groupBy |
kept all 6 groups | `[]` | `INVALID_FILTER` / 400 | twin `photo` vs
`amount`: 400, `the target field "photo" (type "image") ...` |
| per-aggregation `{ closed_at: { $lte: { $field: 'due_f' } } }`
(datetime vs formula) | counted 0 of 6 | 0 | `INVALID_FILTER` / 400 |
400, `"due_f" (type "formula") ...` |
| control: `{ due_on: { $lte: { $field: 'due_on' } } }` | 6 | 0 | 6 / 0,
unchanged | 6 / 0 |

After the change, each per-aggregation refusal logs `where`'s reason
verbatim, and the REST pin compares it against the twin's diagnostic.

## Hypotheses

- **H1, confirmed.** On the base, `crossClassReferenceViolation` acted
only on `cross-class`, through its two callers.
`CROSS_FIELD_NO_CLASS_REASONS` is `list-or-object`, `file`, `formula`.
`where`'s words are `driver-sql`'s, under `INVALID_FILTER` / 400.
- **H2, none exists.** The sentence "has no scalar stored column a
comparison can read" is written twice, inline, in `driver-sql`'s
`applyCrossFieldComparison` (referent arm and target arm), and exported
nowhere. `objectql` does not depend on `driver-sql`. `@objectstack/spec`
and `@objectstack/core` hold no no-class sentence. Two other no-class
wordings exist, neither of them `where`'s: `@objectstack/formula`'s
`describeColumn` (behind the exported `findCrossFieldClassRefusal`) and
`@objectstack/lint`'s RLS / sharing-rule doors. Per the dispatch, adding
one to `packages/spec` is a spec edit, so this run stopped before it.
The seat answered with C (5952549615); see the decision record below.
- **H3, no changed answer for the existing callers.**
`packages/verify`'s `checkDateBucketParity` calls
`applyInMemoryAggregation(rows, ast)` with no `fields`, so the reference
rules have nothing to judge.
- Read, not run: no other in-repo caller outside `objectql` passes
`fields` either (the dogfood parity tests, `service-analytics`'
bucket-key test, the `driver-mongodb` parity test).
- Measured against the rebuilt dists: `@objectstack/verify` 16 files /
120 tests passed, and dogfood 168 files passed (1 skipped), 1374 tests
passed (9 skipped).
- The one answer that moved is objectstack-ai#21242's own with-field-map pin, and it
moved by the ruling.
- **H4, posture kept, and recorded as rows.**
- Registry-less host: not judged at the per-aggregation filter, at
`having`, or at `applyInMemoryAggregation` without `fields`.
- Audit-opt-out object's row-carried `created_at` / `updated_at`: not
judged at the engine positions. Measured on `SqlDriver`: the `where`
twin refuses it 400 ("the target field "created_at" is not a declared
field"), while the per-aggregation filter counts 6 of 6. That divergence
is pinned in the REST table as the recorded posture.
- objectstack-ai#21336's pins stay green: the registry-less `picked: 3`, `['c1']` and
the audit-opt-out 1 of 4.
- **H5, unreachable, and pinned.** A `multiple: true` select, lookup or
user is refused `INVALID_FIELD` / 400 by the engine's groupBy door and
by its `min` / `max` door before `having` is read. Measured on
`SqlDriver` and in the pin, for a groupBy, `min` and `max` of each. The
same holds for `multiselect` and `json`. The only no-class columns that
reach `having` are a file-family groupBy (every driver) and a formula
groupBy on a driver whose door does not refuse it (the engine's
in-memory path; `driver-sql` refuses a formula groupBy itself).

## The enumeration pin

Spec verdict names: the dispatch's `same-class` is the spec's
`comparable`.

| verdict | `where` (driver-sql) | `having` | `aggregations[i].filter` |
`applyInMemoryAggregation` (with `fields`) |
|---|---|---|---|---|
| `comparable` | answered | answered | answered | answered |
| `cross-class` | 400 | 400 | 400 | 400 |
| `no-class` | 400 | 400; list-or-object is refused earlier by the
groupBy door | 400; a formula KEY is refused earlier by the
materializable-filter door (`INVALID_FIELD`), as in `where` | 400 |
| `unjudged` (type outside `FieldType`) | judged by the driver's own
aliases | unreachable: the registry refuses the object | unreachable:
same | not judged |
| no declaration | 400 | not judged | not judged | not judged |

- **objectql pin.** It sweeps all 15 × 15 ordered pairs at each of the
three engine-side positions. The columns are one per row of
`CROSS_FIELD_COMPARISON_TYPE_CLASSES`, plus `select` with `multiple:
true`, plus a type outside `FieldType`. Each cell's expected answer is
computed from `crossFieldComparisonVerdict` itself. A refused cell must
be 400 before any read, and must name both classes or the no-class
column with its declared type; the filter positions must keep those
names off the wire.
- **What makes it fail until placed.**
- The verdict posture is a `Record` keyed by the spec's verdict union,
so a new verdict is a type error. It is also a runtime failure: the set
of verdicts the sweep meets must equal the record's keys.
- The no-class reasons the sweep covers must equal
`CROSS_FIELD_NO_CLASS_REASONS`.
- The filter slots of `EngineAggregateOptionsSchema` must equal `where`,
`having` and `aggregations[i].filter`.
- The published exports of the two evaluator modules must equal
`applyInMemoryAggregation` (a position) and `bucketDateValue` (not a
position).
- **REST pin.** One row per verdict this object can give: comparable,
cross-class, and no-class for file, list-or-object and formula. Each row
runs at all four positions, empty and populated. Every position's answer
(refusal envelope, or total count) must equal the `where` twin's. For
the no-class rows, the twin's reason, read from its withheld diagnostic,
must appear verbatim in the per-aggregation log line, in
`applyInMemoryAggregation`'s diagnostic and, for the file row, in the
`having` message.

## Reverse verification (at `623b7474bd`, before the merge)

The mutation was made with `node scripts/ablation-replace.mjs` and its
EXIT / INT / TERM trap restore.

- **Mutation.** It replaced only the seam's no-class arm, `if
(verdict.verdict === 'no-class') return noClassReason(...)`, with an
always-false guard carrying the marker `ABLATION_21299`. Anchor 1 → 0;
blob `f4c9c878df05` → `eb68be65ef10`.
- **Build.** `objectql` was rebuilt (exit 0). `ablation-dist-preflight`
found the marker in 4 built files.
- **Prediction, then reading.** The no-class refusal pins go red and the
controls stay green. That is what happened:
- `objectql`: **10 failed / 303 passed** (313). The 10 are the three
sweeps, the three filter shapes, the `having` image shape, the two
`addDays`-against-a-formula rows (per-aggregation and `having`, both
answered with the arm off), and the audit-on `created_at` vs image
control. The comparable, cross-class, census, no-declaration,
`multiple`-seam and same-class rows stayed green, as did all of
`engine-aggregate-filter.test.ts` and
`engine-aggregate-having-comparand-shape.test.ts`.
- `rest`: **3 failed / 23 passed** (26). The 3 are the no-class rows
(file, list-or-object, formula). The comparable and cross-class rows,
the audit-opt-out posture row and every earlier test stayed green.
- **Restore.** Proven four ways: the blob equals HEAD (`f4c9c878df05`),
`git diff HEAD` is empty, `--absent` preflight passes after a rebuild
("marker absent from all 14 built files", whole tree clean), and `git
status --porcelain` is empty. The re-run was 313 / 26 passed.

## Verification (all heavy runs through `os-verify-lock.sh`)

The suites below ran at `be8d2c428b` (the merge). Two commits followed.
`251922e411` gives the pin file's two single-choice columns their
`options`, the legal declaration `FieldSchema` now requires;
`466e632c33` corrects one changeset sentence. At `251922e411` that file
was re-run (26 passed) and `objectql` was re-typechecked (exit 0, test
layer held at 40 / 234). The whole gate union was re-run at `466e632c33`
(below).

- Full `@objectstack/objectql`: `--project local` 365 files, **7381
passed**; `--project repo` 1 file, 5 passed. `VERDICT command-exit 0`.
- `@objectstack/rest` `aggregation-filter-where-doors.test.ts`: 26
passed. `@objectstack/verify`: 16 files, 120 passed. Dogfood (`pnpm
--filter @objectstack/dogfood exec vitest run`) against the rebuilt
dists: 168 passed + 1 skipped files, 1374 passed + 9 skipped tests. Each
run's verdict was `command-exit 0`.
- Typecheck:
- `pnpm --filter @objectstack/objectql run typecheck` exit 0. Its test
layer held at 40 files / 234 errors, unchanged, so the new pin file adds
none.
- `pnpm --filter @objectstack/rest run typecheck` exit 0, test layer 0
errors.
- Build after the merge: `turbo run build
--filter='@objectstack/dogfood^...' --filter=@objectstack/rest
--filter=@objectstack/verify`, 63 of 63 tasks.
- Gates, at `466e632c33`:
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 65 commands. All 65 ran with exit 0, each exit code
recorded beside the printed command.
- `--ran` reports "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN (a
DERIVED zero)".
- In the earlier pass at `251922e411`, `check:dual-build-cjs-loads`
answered `PREREQUISITE NOT MET` (exit 3: 8 unrelated packages had no
`dist/`). Those packages were built (41 of 41 turbo tasks from cache),
and the gate exited 0 there and in the final pass.
- The four roster gates whose rosters sit under my paths also exited 0:
`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`.
- NOT MEASURED, because their argv takes workflow values and CI runs
them: `check-issue-citations --census`, `check-shard-attestation`,
`check-test-completeness`.
- Lint, a proven narrowing:
- Command: `pnpm exec eslint --no-inline-config --format json` on the 5
changed `.ts` files gives 5 results, 0 errors, 0 warnings.
- Why the narrowing holds: `eslint.config.mjs` is not type-aware (no
`parserOptions.project`; header near `:328`), so this diff cannot move
any untouched file's verdict.
  - The full `pnpm lint` is CI's.

## Deviations (both accepted by the seat, 5952549615; the `rest` test
file is declared on the cli seat post, 5952559844)

- `packages/rest/src/aggregation-filter-where-doors.test.ts` is outside
the claim's file surface, and it is a test only. The `where` twin can
only be refused on a real `driver-sql`, which `objectql` does not depend
on. This is the same harness and the same reason as objectstack-ai#21255's deviation
2.
- The seam judges the `no-class` answer for an `addDays` pair too.
Before, an `addDays` pair against a formula was answered at both
positions. One against a file or list column was refused, but in the
`addDays` pair rule's words: that rule reads those types as `text`, so
it answered with a cross-class or an offset sentence (read from the
source, not measured). It now gets the no-class sentence. This stays
within "no-class references at these positions", and the changeset says
so.

## Decision record (H2): C

The seat chose **C** in 5952549615. `objectql` keeps the local copy
(`noClassReason`), on the `crossClassReason` precedent, which landed
with objectstack-ai#21255 (PR objectstack-ai#21297, contract review PASS 5944780154). The REST twin
pin is the drift guard. Two options were not taken:
- **A**, a pure sentence function beside the verdict in `packages/spec`:
it adds a published spec export (`Clause-②: yes`, spec-lane work) and a
`driver-sql` edit outside this claim. It would also move the cross-class
sentence. It is recorded as a candidate for the spec lane, not filed,
because no reader gets a wrong answer today.
- **B** strands `formula`, which depends on `spec` alone.

**Patch round 1** (`b397060136`, report 5953205116) changed only
`noClassReason`'s docblock, +8/−5, every line inside the JSDoc block. At
that head:
- the pins pass: `objectql` 313 and `rest` 26;
- `objectql` typecheck exits 0;
- gates: 65 derived, 65 run, all exit 0;
- the full suites stand from `be8d2c428b`, because no source line
outside comments changed since then (proven by the filtered diff).

## Acceptance notes (nothing filed)

- `having` refusal messages exceed the REST envelope's 500-character
bound, so the wire cuts the closing clause about how a column's class is
read. The no-class reason sits in the surviving prefix (the REST pin
asserts it on the wire). This predates the PR (objectstack-ai#21255's note). Carrier:
承接者:无.
- The `where` words for a cross-class pair say "is stored as" where the
engine positions say "is" (objectstack-ai#20127's convention for a computed column).
The no-class sentence needs no such reading and is byte-identical.

---

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

---------

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