Skip to content

fix(objectql,spec)!: a groupBy on a multi-value field and a count_distinct on a JSON-stored field are refused INVALID_FIELD / 400 at the engine aggregate door, on every driver (#20808) - #20911

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20808-json-group-distinct-refusal
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20808-json-group-distinct-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20808
Clause-②: no (narrowing)

What this changes

Two more JSON-stored keys are refused INVALID_FIELD / 400 by engine.aggregate, in the engine's words, before any driver is asked. The aggregate × field-type table stops accepting count_distinct over the JSON-stored types, so every reader of that row refuses the pair too. Nothing widens.

The narrowed accept sets, per shape and per door:

shape door before now
groupBy entry naming a multi-value field (multiselect, checkboxes, tags; select, lookup, user, file, image declared multiple: true), as a name or as { field } engine aggregate (@objectstack/objectql): REST query door, flows, hooks, the analytics ObjectQL strategy accepted, one answer per driver 400 INVALID_FIELD at groupBy[i] / groupBy[i].field
count_distinct over a structured-JSON field (json, composite, repeater, record, location, address, vector) engine aggregate accepted 400 INVALID_FIELD at aggregations[i].field
count_distinct over a multi-value field (the same eight declarations) engine aggregate accepted 400 INVALID_FIELD at aggregations[i].field
count_distinct × the ten JSON-stored types (structured-JSON seven plus multiselect, checkboxes, tags) AGGREGATE_FIELD_TYPE_COMPATIBILITY.count_distinct (@objectstack/spec) in the row out of the row; isAggregateCompatibleWithFieldType('count_distinct', t) is false
a dataset measure count_distinct × a JSON-stored type lint rule measure-aggregate-field-type-refused (os validate, and the runtime gate on a dataset save) clean error at authoring / save
the same measure analytics dataset compile leg compiled 400 DATASET_INVALID

The words, as POST /api/v1/data/:object/query returns them (the route sits inside the 500 characters the REST door keeps; the REST pins assert it there):

aggregate('probe_20808'): groupBy[0] names 'tags', a declared select field with multiple: true — a multi-value field, which the engine does not group by. The query was NOT run. Filter by one member instead: where { "tags": { "$contains": VALUE } } counts or lists the records that hold VALUE, one query per member. A list of values is no group key the drivers share: one grouped each list apart, one grouped each serialized list apart, one refused the statement.

aggregate('probe_20808'): aggregations[0].field counts distinct 'meta', a declared json field — a structured-JSON value, which the engine does not count distinct. The query was NOT run. Count distinct values of a field that stores one scalar value: store the part you count in a field of its own and count_distinct that field, or count the rows with count. A JSON-stored value is no distinct key the drivers share: one counted every row apart, one compared the serialized text, one refused the statement.

The thrown error carries code: 'INVALID_FIELD', status and httpStatus 400, field, fields (every offender), object and param (groupBy or aggregations).

Landing site.

  • packages/objectql/src/group-by-structured-json-door.ts: the structured-JSON door gains its second class, the multi-value field, judged by @objectstack/spec/data's isMultiValueField in the SAME walk over the entries, so the first offending position is named whichever class it is. The export is renamed assertGroupByNamesNoJsonStoredField. The structured-JSON words keep their verdict and route; their reason clause now also holds for vector (the review note carried on the previous door's landing).
  • packages/objectql/src/count-distinct-json-stored-door.ts (new): assertCountDistinctNamesNoJsonStoredField. The TYPE half asks the spec table (isAggregateCompatibleWithFieldType('count_distinct', type)), never a second list. The DECLARATION half asks isMultiValueField, because a per-type table cannot see multiple: true. Not judged: any other function, a count_distinct naming no field, an undeclared name, a host with no field map, and a type outside FieldType (the table is fail-closed on vocabulary, so an introspected integer or object column is left alone).
  • packages/objectql/src/engine.ts: one call each, at the entry of aggregate, right after the credential refusal and in the order groupBy, then count_distinct.
  • packages/spec/src/data/aggregate-field-type-compatibility.ts: the count_distinct row is every FieldType except JSON_STORED_AGGREGATE_FIELD_TYPES (spelled out, held equal to STRUCTURED_JSON_TYPES ∪ MULTI_OPTION_TYPES by the pin, as the file does for its other classes). The TSDoc states the ground, the measurement, and the third reader.

INVALID_FIELD, an existing code: the verdict is about the named field's type at a position, the question the structured-JSON door beside it answers the same way.

Before, measured on origin/main 42d78b97fe

Through POST /api/v1/data/:object/query (the real RestServer route over ObjectStackProtocolImplementation and ObjectQL). Drivers: InMemoryDriver, SqlDriver on SQLite (better-sqlite3), and SqlDriver on a private PostgreSQL 16.13 started for this run. Three rows; two of them hold EQUAL values under every JSON-stored field.

query InMemoryDriver SQLite PostgreSQL 16
groupBy title (text) / status (single-value select), the controls 200, x 2 · y 1 / a 2 · b 1 same same
groupBy each of the 8 multi-value declarations, and { field: 'tags' } 200, one group per array 200, one group per serialized array 500 DATABASE_ERROR (could not identify an equality operator for type json)
count_distinct title / status, the controls 2 2 2
count_distinct json (three different documents) 3 3 500
count_distinct each other structured-JSON type 3 2 500
count_distinct each multi-value declaration 3 2 500
count over json / tags (unchanged) 3 3 3

After, the same run on this branch

Every multi-value groupBy and every JSON-stored count_distinct above answers 400 INVALID_FIELD in the engine's words on all three drivers, naming the position, the field and its declaration. The controls and count answer exactly as before. The InMemoryDriver cells of both runs come from an uncommitted scratch script over the built packages; check:driver-memory-census refuses a new test consumer of that driver without a ruling, so the committed memory cell is the recording driver below, by construction.

Hypotheses (zone 2): which held

  • H1: held, refined. The door is group-by-structured-json-door.ts at the aggregate entry, and the multi-value refusal is one more class there. It is judged on isMultiValueField, which reads the declaration AND the type: select, radio, lookup, user, file, image carry multiple (MULTI_CAPABLE_TYPES; radio plus multiple is already refused at parse), while multiselect, checkboxes and tags are multi-valued by type with no multiple at all (MULTI_OPTION_TYPES). Those three split exactly the same way (measured), so they are in the class; judging multiple: true alone would have left them grouping per serialization. A multi-valued lookup (and user) is in scope: it is a multiple: true field, and it measured the same (PostgreSQL 500).
  • H2: held. Both layers moved, and the door asks the table. The table's readers: ① the lint rule validateDatasetMeasureAggregates (gating, run by os validate / os lint and, since the dataset runtime gate, on a runtime dataset save), which now refuses at save time a dataset measure pairing count_distinct with a JSON-stored field; ② the analytics dataset compile leg (assertAggregateFieldTypeCompatible), 400 DATASET_INVALID; ③ measureResultType (result typing only: a refused pair gets no corrected type); ④ the new engine door; ⑤ the prose of two step-18 migration entries. The narrowing on authored metadata is dataset measures only; the census below finds zero authored count_distinct anywhere. Ablation A3 below proves the door reads the table: putting the old row back reds the engine pins.
  • H3: measured, a reach remains. Through AnalyticsService wired as AnalyticsServicePlugin's own bridges (executeAggregate to engine.aggregate, executeRawSql to engine.execute), after merging origin/main 00a92e18da (which carries the analytics structured-JSON dimension door):
    • the ObjectQL strategy (the in-memory driver) reaches this door: a cube or dataset dimension on a multi-value field and a cube measure meta_count_distinct answer this 400;
    • NativeSQLStrategy on SQLite and PostgreSQL does NOT: a cube or dataset dimension on a multi-value field answers one group per serialized array on SQLite and 500 on PostgreSQL; an inferred cube measure FIELD_count_distinct over a JSON-stored field answers 2 on SQLite and 500 on PostgreSQL; a dataset measure count_distinct over a select declared multiple: true (the table sees only select) answers 200 on SQLite (distinct serialized arrays) and 500 on PostgreSQL;
    • a dataset measure count_distinct over a JSON-stored TYPE is refused by the compile leg on every driver (DATASET_INVALID).
      The native-SQL reach is reported to the seat, not fixed here. The memory cube (MemoryAnalyticsService) has zero constructors outside tests (git grep "new MemoryAnalyticsService" excluding tests: 0; including them: 5 files), so it was not measured.
  • H4: held; zero hits. Census below.

Census (before narrowing)

A groupBy / grouping / dimension on a multi-value field, and a count_distinct on a JSON-stored field, in datasets, reports, views, dashboards, pages and code.

  • examples/** at 42d78b97fe (unchanged at this head): the four objectstack.config.ts apps and embed-objectql. Stacks loaded through tsx (showcase through its metadata modules, its plugin imports unbuilt) and walked for every grouping-shaped position.
    • Multi-value fields: todo 1 (todo_task.tags), showcase 8 (showcase_field_zoo.f_multiselect, f_checkboxes, f_tags, f_lookups, f_users; showcase_project.labels, team_members; showcase_task.labels); crm, multi-package and embed-objectql 0. Structured-JSON fields: showcase 10.
    • Grouping positions naming ANY declared field (the positive control): showcase 74 (kanban groupByField, chart dimensions, dataset dimensions, page dimensions, report xAxis). Report rows / columns: status, priority (showcase), status, priority, owner, category (todo). Hits naming a multi-value or structured-JSON field: 0.
    • count_distinct: 0 occurrences in examples/.
  • The published hotcrm stack: objectstack-ai/hotcrm cloned at 4ca8e2d4bb (the repository's main; the npm tarball itself was not read). Its dependency tree was not installed, so the census is by source text over src/**, test/** and scripts/**.
    • Multi-value fields: crm_knowledge_article.tags (select, multiple: true), and attendee_contacts / attendee_users (lookup, multiple: true) in an activity action. Structured-JSON fields: billing_address (contract, quote, account), shipping_address (quote), address (lead), office_location (account).
    • Grouping positions: groupByField status, owner_id, stage, crm_account, channel; xAxis stage, status, priority, owner; report rows / columns over dataset dimensions industry, type, lead_source, last_contacted_date, stage, owner, forecast_category, close_quarter; dataset dimension field: values none of the fields above. Hits: 0. Its _picklists.ts already records why a grouped field stays single-valued ("a multiple: true column groups by the COMBINATION").
    • count_distinct: 1 occurrence, a comment in scripts/analytics-reconcile/reconcile.ts. Hits: 0.
  • packages/platform-objects: 0 count_distinct.

No hit, so neither narrowing went back to triage.

Scope beyond the declared surface, and why

The claim names packages/objectql/src/**, the one spec file, and rest pins. Four more places move, each because the ruled table change makes a pin red or a shipped sentence false. They sit in separate commits so each can be judged or dropped on its own:

  • packages/lint: the table-agreement pin accepts count and count_distinct over every declared FieldType goes red on the ruled row, so it is triaged (count over every type; count_distinct over every type but the ten, which it refuses). The refusal hint said "count / count_distinct accept every type"; it now says count_distinct accepts every type but the JSON-stored ones.
  • packages/services/service-analytics: the census pin the refused set … 155 pairs counts the table's refused pairs, so it moves to 165 with a distinct bucket of 10. The compile leg's words for a count_distinct refusal fell into the "derives a NUMBER … coerces the stored form" branch and then said "count/count_distinct accept every type"; DIVERGENCE_BY_AGGREGATE gains a count_distinct branch (equality, not arithmetic or order), REMEDY_BY_SOURCE_CLASS gains the distinct prescription, and the two other sentences stop claiming count_distinct accepts every type. A new case pins those words. The dispatch said not to edit packages/services/**; the two claims on that package (the analytics dimension door and the analytics seam lowering) had both landed (00a92e18da, 793fb839) before this edit, and no file here overlaps theirs.
  • packages/spec/src/migrations/entries/semantic/18.dataset-measure-*.ts (+ the regenerated registry.ts): the min / max entry's route "① … count / count_distinct, which accept every type" is false for the ten types now; the sum / avg entry's surface and acceptance prose name the count_distinct rider this change's ADR-0087 marker points at.
  • content/docs/deployment/validating-metadata.mdx: the sentence "count/count_distinct are accepted over every type".

Tests

  • New packages/objectql/src/engine-json-stored-group-distinct-door.test.ts (7 tests, recording driver, so the in-memory cell by construction): every multi-value declaration refused as a groupBy with the full envelope, the declaration and the $contains route; the { field } form and the first offending position across both classes; every structured-JSON type and multi-value declaration refused as a count_distinct, and the first of two offenders named; the REST door into findData; controls (scalar group keys and distinct counts, count over JSON-stored columns, the $contains route itself) reach the driver; GUARDs: the count_distinct door agrees with the spec table on every FieldType (floor: exactly 10 refused) and refuses every flagged multi-capable type; no verdict without a field map, for an undeclared name, an off-vocabulary type or any other function.
  • New packages/rest/src/data-json-stored-group-distinct-door.test.ts: SQLite always, PostgreSQL / MySQL where OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL are set. Both shapes answer 400 with the route in the REST body and zero reads; the controls (status groups, title and status distinct counts) and the named route (count with where { tags: { $contains } } answers a 3 · b 2) are served by the driver. ⚠️ No CI job sets those URLs for this package, so the live cells run only locally. Local run with the private PostgreSQL 16.13: 8 passed (sqlite 4, live postgres 4) / 4 skipped (mysql, no URL).
  • Fixture triage:
    • engine-group-by-json-door.test.ts (the structured-JSON door's pin) named a multiple: true select as a CONTROL reaching the driver. That branch is closed now, so the control is replaced by a single-value select. Its GUARD over every FieldType now expects the multi-option types refused too.
    • The lint and service-analytics pins above.
  • Suites, all at fb239eb2f3 (the merge of origin/main 00a92e18da); the later commits touch only the changeset:
    • pnpm --filter @objectstack/objectql test: 347 files / 6799 passed.
    • pnpm --filter @objectstack/rest test with the live PostgreSQL URL: 239 files / 4731 passed / 39 skipped.
    • pnpm --filter @objectstack/spec test: 582 files / 17164 passed / 1 todo.
    • pnpm --filter @objectstack/lint test: 117 files / 5438 passed.
    • pnpm --filter @objectstack/service-analytics test: 145 files / 3326 passed.
    • typecheck for objectql, spec, lint, rest and service-analytics: exit 0. Each check:test-typecheck held its ledger (objectql 40 files / 234 errors / 65 signatures, spec 52 / 249 / 137, lint 2 / 6 / 2, rest 0), so the new test files compile.

Reverse verification (three ablations), each from committed code through scripts/ablation-replace.mjs WRAP (trap-restored), the package rebuilt, and ablation-dist-preflight finding the marker in the built files before any reading:

ablation mutation predicted observed
A1, multi-value groupBy the isMultiValueField(...) class test fed a global flag that is never set (anchor 1 to 0, marker 0 to 1, blob 322c8098d2d1 to 39a2feb82b08); objectql dist marker in 4 files red objectql pins 4 failed / 9 passed (multi-value groupBy, object form, findData, the structured-JSON GUARD over FieldType); rest pins 2 failed / 12 passed / 7 skipped (sqlite + postgres multi-value groupBy); every count_distinct case and control green
A2, count_distinct door the engine call fed an always-undefined property instead of query.aggregations (blob cd1e9e39967f to 3123c092583e); marker in 4 files red objectql 2 failed / 11 passed; rest 2 failed / 12 passed / 7 skipped (sqlite + postgres count_distinct); groupBy cases green
A3, the table row count_distinct: fed the old every-type row (blob 94cf5a2e3f56 to a343f47f2212); spec dist marker in 4 files red, and the engine door red with it objectql 3 failed / 10 passed (count_distinct refusal, findData, the table GUARD); rest 2 failed; spec pin 3 failed / 21 passed; lint pin 1 failed / 23 passed

Restore leg for each: blob equals HEAD, git diff HEAD empty. After rebuilding spec and objectql, --absent found all three markers absent (spec 230 built files, objectql 14), whole-tree porcelain empty, and the pins green again (objectql 13 passed; rest 14 passed / 7 skipped; spec 24; lint 24).

Gates

node scripts/pm/dispatch-gates.mjs --commands (no paths) at f53718f1ee derived 117 commands over 17 paths vs merge base 00a92e18d. All 117 were run at f53718f1ee with their exit codes recorded, and --ran reconciles them: 117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. Among them:

  • check:adr-0087-registration --base origin/main: not-required (already-registered dataset-measure-aggregate-field-type-refused) accepted.
  • check:changeset-no-major, check-empty-changeset ("No changeset from the merge base modified or deleted by this diff"), check:doc-authoring, check:nul-bytes, check:issue-citations, check:engine-double-contract, check:driver-memory-census, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage.
  • The @objectstack/spec generated-artifact gates, check:migration-registry, check:spec-changes and check:upgrade-guide included. The registry was regenerated for the amended entries, and again after the merge (no diff).
  • check:skill-examples, check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3). After turbo run build --filter='./packages/*' --filter='./packages/*/*' they answered 0; that build is of fb239eb2f3, whose package sources equal this head's.

Lint, narrowed and proven at f53718f1ee: eslint with inline config disabled, over the 15 changed .ts files, found 15 files, 0 errors, 0 warnings. Three facts make this narrowing a measurement:

  • The population comes from eslint's own config: isPathIgnored is false for all 15.
  • The count comes from the results: 15.
  • Untouched files cannot change verdict: parserOptions.project and projectService are null for every file, so type-aware linting is not enabled.

Changeset

.changeset/20808-json-stored-group-distinct-refused.md:

  • @objectstack/objectql minor and @objectstack/spec minor, each with its own BREAKING banner. @objectstack/lint and @objectstack/service-analytics are patch, for their words.
  • Clause-②: no (narrowing), and exactly one ADR-0087 marker: not-required (already-registered dataset-measure-aggregate-field-type-refused). The table row is that family's narrowing, and the non-temporal sum / avg narrowing rode the same id the same way; this diff amends that entry's prose to name the rider. The engine-door halves refuse a query shape, not a stored one.
  • It names the two shapes the structured-JSON groupBy entry of this same release lists as unchanged. That pending note is left as it landed: rewriting it reds check-empty-changeset for a human's confirmation.

No export or published type changes (assertGroupByNamesNoJsonStoredField and the new door are internal; objectql's root and ./core exports are unchanged).

Acceptance notes

  • Reported to the PM, not filed here (the same family: a JSON-stored column as a group, distinct or aggregate key, answering per driver with a PostgreSQL 500):
    • The native-SQL analytics reach (H3 above). Landing: packages/services/service-analytics, beside its structured-JSON dimension door.
    • min / max over a JSON-stored field at the engine door: POST /api/v1/data/:object/query with max(meta) answered {a:1} on memory, '{"b":1}' (a string) on SQLite and 500 on PostgreSQL (function max(json) does not exist), and max(tags) the same way. The table already refuses those pairs, and its dataset legs enforce that; the engine enforces only the count_distinct row, as ruled here.
  • A single-value media field (file, image, avatar, video, audio) is a JSON column on a deployment that has not moved its media columns (JSON_COLUMN_TYPES' header in driver-sql), so a groupBy or count_distinct on one there would split the same way. This is by reading, not measured: a fresh deployment creates the string column. Not judged here, because the type alone cannot tell the two deployments apart.
  • A refusal relayed through the analytics ObjectQL strategy names the engine's position (groupBy[0], aggregations[0].field), not the cube member the caller wrote. The same note was made on the structured-JSON door.

Generated by Claude Code

…istinct on a JSON-stored field at the engine aggregate door (#20808)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…ed count_distinct refusals (#20808)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
… false, and the lint fixture that pinned the old row (#20808)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…nt_distinct narrowings (#20808)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…refusal, and the census pin the narrowed table moves (#20808)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
… unchanged the two shapes this release also refuses (#20808)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…; this entry names the two shapes it narrows (#20808)

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/lint, @objectstack/objectql, @objectstack/service-analytics, @objectstack/spec, touching 23 documentable anchor(s).

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

  • content/docs/ai/natural-language-queries.mdx (via count_distinct (symbol, a field of const object AGGREGATE_FIELD_TYPE_COMPATIBILITY), count_distinct (literal, a string literal in DIVERGENCE_BY_AGGREGATE; a string literal in REMEDY_BY_SOURCE_CLASS; a string literal in a comment on a changed line; a string literal in jsonStoredDistinctTargets))
  • content/docs/api/client-sdk.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query))
  • content/docs/api/wire-format.mdx (via /api/v1/data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/data-modeling/queries.mdx (via count_distinct (symbol, a field of const object AGGREGATE_FIELD_TYPE_COMPATIBILITY), count_distinct (literal, a string literal in DIVERGENCE_BY_AGGREGATE; a string literal in REMEDY_BY_SOURCE_CLASS; a string literal in a comment on a changed line; a string literal in jsonStoredDistinctTargets), /api/v1/data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/deployment/validating-metadata.mdx (via count_distinct (symbol, a field of const object AGGREGATE_FIELD_TYPE_COMPATIBILITY), count_distinct (literal, a string literal in DIVERGENCE_BY_AGGREGATE; a string literal in REMEDY_BY_SOURCE_CLASS; a string literal in a comment on a changed line; a string literal in jsonStoredDistinctTargets))
  • content/docs/kernel/contracts/data-engine.mdx (via count_distinct (symbol, a field of const object AGGREGATE_FIELD_TYPE_COMPATIBILITY), count_distinct (literal, a string literal in DIVERGENCE_BY_AGGREGATE; a string literal in REMEDY_BY_SOURCE_CLASS; a string literal in a comment on a changed line; a string literal in jsonStoredDistinctTargets))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query))
  • content/docs/protocol/objectql/query-syntax.mdx (via count_distinct (symbol, a field of const object AGGREGATE_FIELD_TYPE_COMPATIBILITY), count_distinct (literal, a string literal in DIVERGENCE_BY_AGGREGATE; a string literal in REMEDY_BY_SOURCE_CLASS; a string literal in a comment on a changed line; a string literal in jsonStoredDistinctTargets))
  • content/docs/ui/dashboards.mdx (via count_distinct (symbol, a field of const object AGGREGATE_FIELD_TYPE_COMPATIBILITY), count_distinct (literal, a string literal in DIVERGENCE_BY_AGGREGATE; a string literal in REMEDY_BY_SOURCE_CLASS; a string literal in a comment on a changed line; a string literal in jsonStoredDistinctTargets))

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

  • content/docs/releases/v15.mdx (via count_distinct (symbol, a field of const object AGGREGATE_FIELD_TYPE_COMPATIBILITY), count_distinct (literal, a string literal in DIVERGENCE_BY_AGGREGATE; a string literal in REMEDY_BY_SOURCE_CLASS; a string literal in a comment on a changed line; a string literal in jsonStoredDistinctTargets))
  • content/docs/releases/v17/17-0.mdx (via count_distinct (symbol, a field of const object AGGREGATE_FIELD_TYPE_COMPATIBILITY), count_distinct (literal, a string literal in DIVERGENCE_BY_AGGREGATE; a string literal in REMEDY_BY_SOURCE_CLASS; a string literal in a comment on a changed line; a string literal in jsonStoredDistinctTargets))
  • content/docs/releases/v17/17-5.mdx (via count_distinct (symbol, a field of const object AGGREGATE_FIELD_TYPE_COMPATIBILITY), count_distinct (literal, a string literal in DIVERGENCE_BY_AGGREGATE; a string literal in REMEDY_BY_SOURCE_CLASS; a string literal in a comment on a changed line; a string literal in jsonStoredDistinctTargets))

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
  • 7 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 — 138 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 cf684c98eb8b10398729befff5220ae5c84014b9 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 cf684c98eb8b10398729befff5220ae5c84014b9 → 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: f53718f1ee9182aa33ff33e83218ef15dd164ab7
Local-runs: none

PR #20911 for card #20808, reviewed at the head above (it had not moved when read), as the net diff against main (merge base 00a92e18da; 17 files, +1067 / -81), the card's body and its four comments (triage 5908897679, claim 5914370197, os-dev-report 5916514958, seat answer 5916552699), PR #20804 (#20783, 157baa75f) as the door this extends, and the head's check-runs. Read-only: no worktree, build, test, gate or ablation ran here. Nothing below repeats the dispatching seat's conclusions; each item is judged against the diff.

① Derived judgments

Every accept-set or public-surface change the diff implies, each named right or wrong:

  1. Engine aggregate, positions groupBy[i] and groupBy[i].field (packages/objectql/src/group-by-structured-json-door.ts, called at the verb's entry in engine.ts right after the credential refusal): an entry naming a declared multi-value field is refused INVALID_FIELD / 400 before any driver is resolved. The class is @objectstack/spec/data's isMultiValueField: the three MULTI_OPTION_TYPES by type, and the multi-capable types by multiple: true (radio plus multiple is refused at parse in field.zod.ts, so the changeset's five-type list is complete). Both classes are judged in one walk, so the first offending position is named whichever class it is and fields lists every offender. RIGHT: triage's direction, $contains named as the route, no bucket-per-member meaning defined. A pure narrowing.
  2. Engine aggregate, position aggregations[i].field for function: 'count_distinct' (new packages/objectql/src/count-distinct-json-stored-door.ts): refused INVALID_FIELD / 400 when the declared field's type is one the spec table's count_distinct row refuses (asked through isAggregateCompatibleWithFieldType, never a second list) or the declaration is multi-value (isMultiValueField). Not judged: any other function, no field or *, an undeclared name, a host with no field map, a type outside FieldType (fail-closed vocabulary, "cannot answer, do not block"). RIGHT. count_distinct is the one deduplicating spelling on the engine contract (AggregationNodeSchema.distinct is a retired key since [spec/drivers] AggregationNode.distinct is honoured by the in-memory fallback and ignored by every SQL face — one query, two numbers (ADR-0049) #6815), so the door has no second spelling to miss. A pure narrowing.
  3. AGGREGATE_FIELD_TYPE_COMPATIBILITY.count_distinct (@objectstack/spec, a published const) drops ten members, STRUCTURED_JSON_TYPES (7) plus MULTI_OPTION_TYPES (3), spelled out as a module-private JSON_STORED_AGGREGATE_FIELD_TYPES and held equal to the two sets by the spec pin; isAggregateCompatibleWithFieldType('count_distinct', t) answers false for them. The count row and every other row are byte-unchanged. The two new consts are not exported and no api-surface file moves. RIGHT on the table's own ground ("can every backend give one answer"). A pure narrowing.
  4. Lint rule measure-aggregate-field-type-refused (validateDatasetMeasureAggregates; registered tier: 'gating', surfaces: CLI_AND_RUNTIME, runtimeTypes: ['dataset']): now errors on a dataset measure count_distinct over any of the ten types, at os validate / os lint and at a runtime dataset write through @objectstack/lint/runtime (runRuntimeAuthoringRules, imported by metadata-protocol's runtime authoring gate). By construction of the table: no lint code decides a pair, and the diff changes only the hint's words and the pin. RIGHT; a narrowing that follows the row.
  5. Analytics dataset compile leg assertAggregateFieldTypeCompatible (dataset-compiler.ts): 400 DATASET_INVALID for the same ten pairs, by construction of the table. The diff changes only the message: DIVERGENCE_BY_AGGREGATE gains a count_distinct branch (equality, not arithmetic or order; without it a count_distinct refusal fell into the "derives a NUMBER" sentence, which is false), REMEDY_BY_SOURCE_CLASS gains the distinct prescription, and two sentences stop saying count_distinct accepts every type. Both helpers are referenced at exactly the two message sites and nowhere else, so nothing moves beyond the table it reads. The census pin moves 155 to 165 with a distinct bucket of 10, which is arithmetic over the table. RIGHT.
  6. measureResultType: listed by the PR as a third reader. It answers undefined for every aggregate but min / max, so count_distinct result typing is unchanged by the row. True and vacuous; no change.
  7. The structured-JSON groupBy refusal (the [finding] groupBy on a json field answers 500 DATABASE_ERROR on PostgreSQL, one merged group on memory, and one group per serialized value on SQLite #20783 door): verdict, code, envelope and route unchanged; the reason clause now reads "merged documents that differ into one group (or split them per array)", true for vector where the old sentence was not. Words only. RIGHT.
  8. The rename assertGroupByNamesNoStructuredJsonField to assertGroupByNamesNoJsonStoredField and the new door module: neither is reachable from @objectstack/objectql's root or ./core (the only references are engine.ts and the two suites). No public surface moves. RIGHT.
  9. packages/spec/src/migrations/registry.ts (generated): the two step-18 entries' prose only; no entry added or removed; the regenerated text mirrors the entry files exactly. RIGHT.
  10. Fixture triage: engine-group-by-json-door.test.ts replaces its multiple: true select CONTROL with a single-value select (item 1 closes that branch) and its GUARD now expects the multi-option types refused; the lint and service-analytics pins as in items 4 and 5. Each is the consequence of the ruled row, not a respelling. RIGHT.

Nothing widens: the count row, every scalar-stored group key and distinct count (a single-value select or lookup included), count over a JSON-stored column, the having, filter and sort positions, and the undeclared-name path are untouched, and the suites' CONTROL cases pin those paths reaching the driver.

The refusal texts an author reads: the multi-value groupBy words name the position, the field, its declaration (select field with multiple: true, or the bare type for an inherently-multi type), the verdict, that the query was NOT run, the $contains route, and a reason true to the measured split (one grouped each list apart, one each serialized list apart, one refused the statement). The count_distinct words do the same with the distinct route (one member per query for a multi-value field; a scalar field of its own or count for structured JSON) and a reason true to 3 / 2 / 500. The route precedes the reason so it sits inside the 500 characters the REST door keeps, and the rest pins assert it on the REST body for every declaration.

② Semver level

.changeset/20808-json-stored-group-distinct-refused.md: @objectstack/objectql minor and @objectstack/spec minor, each under its own **BREAKING** banner naming the narrowed positions; @objectstack/lint patch and @objectstack/service-analytics patch for their words. RIGHT: the two packages whose published accept set narrows carry minor plus the banner (the launch-window grade for a narrowing); the two readers change no accept set of their own, each refusing what the table refuses by construction, and the spec banner names both readers in the same body, which changesets writes into every listed package's CHANGELOG, so a lint or service-analytics consumer is told in its own log. check-changeset-no-major reads this shape as discharged (two patch entries on touched packages beside two minor entries on the packages that carry the level), and the head's Check Changeset run is green. @objectstack/rest gains a test file only: no entry, right.

The declaration, no (narrowing) on the Clause-② line of the PR body and the changeset, is right by scripts/pm/clause2-line.mjs's own grammar: yes (narrowing) would assert a widening beside the narrowing, and nothing here widens. Four accept sets narrow and none widens: the engine aggregate door (two positions), the spec table's count_distinct row, lint's authoring-time and save-time rule, and the analytics compile leg. Triage's yes (narrowing) was the same intent in the spelling that reader refuses; the claim already corrected it.

ADR-0087: exactly one marker in the changeset, not-required (already-registered dataset-measure-aggregate-field-type-refused). The category fits: the id resolves at HEAD and already existed at the merge base (the entry file is modified, not added); the only metadata-facing narrowing is a row of the table that entry registers the family for ("an aggregate the field's type accepts, per AGGREGATE_FIELD_TYPE_COMPATIBILITY"); and the precedent is exact, #17559 rode the same id the same way for the non-temporal sum / avg leg. The engine-door halves refuse a query shape, not a stored row. The marker and the whole changeset body carry no FROM/TO table and no arrow (none of the three arrow spellings and no table row anywhere in the file). The dev's reading of check:adr-0087-registration (not-required (already-registered …) accepted) is consistent with that gate's already-registered rule as written.

③ Boundary flags

Dev flags (os-dev-report 5916514958), each answered:

  • InMemoryDriver pin by construction (a recording driver): answered, accepted. Both doors throw before a driver is resolved (the objectql suite asserts zero reads), so the memory cell is by construction; check:driver-memory-census is a ledgered, closed census that refuses a new test consumer of that driver, and the fix(objectql)!: a groupBy on a structured-JSON field is refused INVALID_FIELD / 400 at the engine aggregate door, on every driver (#20783) #20804 precedent did the same. The real InMemoryDriver before / after cells are the dev's uncommitted probe: reported, not pinned.
  • No driver-level pins: answered, accepted. The claim said no driver file; AGGREGATION_CASES reaches drivers directly and cannot observe an engine refusal. The live SQL cells are the rest pin (data-json-stored-group-distinct-door.test.ts): SQLite always, PostgreSQL / MySQL only where OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL are set, which ci.yml does for driver-sql, metadata-protocol and runtime and not for @objectstack/rest. The PostgreSQL cell is therefore the dev's local run (8 passed / 4 skipped), the same gap the sibling door suites carry. Carried, not a defect of this PR.
  • One merge of main through os-regen-merge.sh: one merge commit on the branch (fb239eb2f3, parents 95b7278ad6 and 00a92e18da); the only file differing from both parents is the generated registry.ts, whose net diff against main is exactly the two entries' prose. Consistent with a regen merge and with nothing else riding it. The two commits after it touch only the two changeset files.
  • Pending [finding] groupBy on a json field answers 500 DATABASE_ERROR on PostgreSQL, one merged group on memory, and one group per serialized value on SQLite #20783 changeset restored byte-for-byte: the diff of .changeset/20783-groupby-structured-json-refused.md between the merge base and the head is empty. True.
  • Out-of-scope findings: the analytics NativeSQLStrategy bypass (on SQL drivers it compiles GROUP BY and COUNT(DISTINCT …) by hand and never reaches the engine) and min / max over a JSON-stored field at the engine (the table refuses those pairs and the dataset legs enforce them; the engine door enforces only the count_distinct row, as ruled). Both are the same family, neither is this card's ruling, and the seat says it files them: escalated to the seat, not to this PR. The PR's own claim on the ObjectQL strategy is TRUE: objectql-strategy.ts builds groupBy from dimension field names (or { field, dateGranularity }) and aggregations as { field, method, alias }, and AnalyticsServicePlugin's auto-bridge renames method to function before calling engine.aggregate, so a cube or dataset dimension on a multi-value field and a FIELD_count_distinct measure over a JSON-stored field reach these doors on that path, which is the wiring the dev measured through. A host-supplied bridge that did not rename would already be off the engine's contract. The two dynamic engine callers in service-analytics (resolveFkAttr's groupBy: ['id', attr] and the display-label pass) now answer this 400 for a multi-value attribute, the same note fix(objectql)!: a groupBy on a structured-JSON field is refused INVALID_FIELD / 400 at the engine aggregate door, on every driver (#20783) #20804 carried.

Seat answers (5916552699), each judged against the diff:

  1. The four out-of-surface edits stay: RIGHT, each is the minimal consequence of the ruled row and nothing more. (a) packages/lint: the pin accepts count and count_distinct over every declared FieldType iterates the rule over FieldType.options, and the rule IS the table, so it reds on the row; the hint sentence "count / count_distinct accept every type" would ship false. The diff is that pin's triage plus three lines of hint, no rule logic. (b) packages/services/service-analytics: the census pin counts the table's refused pairs (red at 155), and the compile leg's words for a count_distinct refusal were false ("derives a NUMBER", "accept every type"). The diff is two message helpers, one pin count and one new pin case; both helpers feed only the thrown DATASET_INVALID message, so no behaviour moves beyond the table it reads. No file overlaps [finding] analytics: a cube / dataset dimension on a json field, compiled by NativeSQLStrategy, answers one group per serialized document on SQLite and 500 on PostgreSQL; the engine door #20783 closes does not see it #20807 (00a92e18da) or #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 (793fb839), and main has not moved on any of the 17 files since the merge base. (c) The two step-18 entries plus the regenerated registry: the min / max entry's route ① said count_distinct accepts every type, false now; the sum / avg entry is the id the marker rides, and its surface / acceptance prose now names the third rider, which keeps already-registered honest. (d) validating-metadata.mdx: one sentence, made true. Nothing beyond these four.
  2. The count_distinct narrowing extends to the multi-option types and to multiple: true declarations: RIGHT, the same class and the same row on the table's own ground. The row is per type; multiselect, checkboxes and tags are stored in the same JSON column and were measured to the same 3 / 2 / 500, and leaving them in the row this PR edits would keep a pair the row claims every backend answers, measured false on PostgreSQL. multiple: true is invisible to a per-type table, so the engine door reads the declaration, the class this card already refuses for groupBy under triage's own words. Census zero. Consumers reached, per door: the spec row (10 types); lint at os validate / os lint and at a runtime dataset save (the 3 multi-option types beyond the structured-JSON 7, by TYPE only); the analytics compile leg, 400 DATASET_INVALID (the same 3, by type only); the engine aggregate door (the 3 by type plus the 5 multi-capable types by multiple: true). The residual seam this leaves, a dataset measure count_distinct over a select declared multiple: true that passes lint and the compile leg, is refused at the engine on the ObjectQL strategy and answers per driver on native SQL, is the out-of-scope finding the seat files, and has the same shape with or without the extension. Triage may narrow back; nothing in the diff prevents it.
  3. .changeset/20783-groupby-structured-json-refused.md stays as landed: RIGHT. Net diff zero; this PR's changeset names the two shapes as the later word; both entries compile into the same release; check-empty-changeset refuses a modification of a pending changeset without a person's confirmation, which the dev's b613f60a0b measured red and f53718f1ee reversed.

Review faces, each read sentence by sentence: the changeset (true; its "Unchanged" list holds because the doors read only groupBy and aggregations); the PR body (true; its one vacuous sentence is measureResultType as a reader, which it is without any change of answer); packages/spec/src/data/aggregate-field-type-compatibility.ts, the declared spec lane (the row, the class definition, the "third reader" and "refuses nothing itself" sentences all match the code and its readers, and the isIncoherentAggregate note stays true since no rate is JSON-stored); the two migration entries' prose (true; the temporal entry's replacement still offering count_distinct is true for a temporal field, and "there is no third entry" stays true of a third change that rides this one); content/docs/deployment/validating-metadata.mdx (true); the refusal texts (true to the measured split, route before reason).

Check-runs on the head, read at 2026-09-30T17:56Z and not polled: 32 runs, zero failures at read time.

  • Success (14): Build Docs; Auto Label; Type Check · source gates; Type Check · debt ledger; Part-of PR must not also close its card; filter; Governed Surface Queue Guard; Check Changeset; Check PR Size; The card this PR closes must claim this branch; Spec property liveness; Check Documentation Links; No other open PR may claim the same single-writer path; No other open PR may claim the same issue.
  • Skipped (2): Console Pin Gate; Packed-tarball smoke (opt-in).
  • In progress (16): Test Core 1/6 through 6/6; Temporal Conformance (live PG + MySQL); Dogfood Regression Gate 1/3 through 3/3; Dogfood Verify CLI; Build Core; Lint & Repo Gates; Type Check · consumer gates; Type Check · workspace; Flag docs affected by code changes.
    The dev reports all 117 derived gate commands at exit 0 on this head; the in-progress runs are the adopting seat's to read when they land, and a red among them re-opens this record.

Implemented-by: claude/issue-20808-json-group-distinct-refusal
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 18:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 975b248 Sep 30, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20808-json-group-distinct-refusal branch September 30, 2026 18:27
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… contract, and an identity-only column is judged as the type it renders (objectstack-ai#20901) (objectstack-ai#20927)

Closes objectstack-ai#20901

Clause-②: yes (narrowing)

- `FormViewSchema.subforms[].columns` references
`InlineGridColumnSchema` (was `z.array(z.any())`): the card's typed
`currency` + `scale` column and its `zzz_not_a_key` column are refused
at the view parse.
- `defineStack`'s cross-reference check
(`packages/spec/src/stack.zod.ts#collectHydratedInlineColumnErrors`,
called from `validateCrossReferences`) re-parses a column that declares
no `type` as the type it renders (`currency` over a `currency` child
field) through `InlineGridColumnSchema`, on both carriers. The card's
identity-only column with `scale` is refused there with the column
schema's own message; there is no second `scale` rule.
- ADR-0087: D3 entries `form-view-subform-columns-closed` and
`inline-grid-column-identity-only-currency-scale-refused`; the registry
was regenerated after merging a `main` that carries objectstack-ai#20903 and objectstack-ai#20911.

Evidence at `feba1a99bd`: pins
`packages/spec/src/inline-grid-column-carriers.test.ts` 13/13;
`@objectstack/spec` suite 584 files / 17179 tests green;
`@objectstack/spec` typecheck green; 114/114 derived gates exit 0.
Ablations, each restored with `git diff HEAD` empty: reference removed,
5 red; cross-reference call removed, 3 red. `os validate` on a probe
stack: identity-only column, exit 1 `STACK_CROSS_REFERENCE_INVALID`;
typed and bogus columns, exit 1 `STACK_SCHEMA_INVALID`; valid columns,
exit 0.

## Acceptance notes

- No check read `subforms[].childObject` before this change
(`validateCrossReferences` read only `form.data.object`), so the
child-object lookup is new here.
- The identity-only check runs in `defineStack` only. A view saved
through the metadata door, or a subform whose child object lives in
another package, gets the schema half alone (NOT MEASURED at the save
door). objectui's `@object-ui/types` mirror is still `z.any()`, and the
render-time warning stays the backstop there.
- `field.zod.ts`'s `scale` describe still names only the declared-type
refusal. PR objectstack-ai#20908 holds that file.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…lowering, lower type-blind without a field map, then delete driver-memory F3 (objectstack-ai#5930 step 4, group 1b) (objectstack-ai#20925)

Part of objectstack-ai#20822
Clause-②: no

objectstack-ai#5930 step 4, **group 1b: F3** (`driver-memory`'s query path). This
follows the seat's answer B (5915193659 on objectstack-ai#20822), read from ADR-0053
D-D1 items 5, 7 and 10 as amended: first route the direct caller, then
make the engine seam type-blind when it has no field map, then delete.
It is one PR in three ordered commits on `main` at `4d0b9cd542`:

| # | Commit | What it does |
|:--|:--|:--|
| 1 | `0bd0e6d4f7` fix(metadata) | `DatabaseLoader.queryHistory` in
driver mode runs `lowerFilterCondition` on its own `where`. The reader
is typed by the history object the loader syncs (`recorded_at` is
`Field.datetime`). The loader becomes a seam (item 5). |
| 2 | `5317b5aa22` fix(objectql) | `declaredDatetimeLowering`'s
absent-map branch drops the reader, so an object with no field map is
lowered type-blind (item 7). An object with a field map keeps the typed
scope byte-identical. |
| 3 | `e15606bae2` refactor(driver-memory) | F3's four whole-day sites
are deleted. The 43 direct-call tests are routed through
`lowerFilterCondition` with the declared-datetime reader. New pins cover
item 5 (one cell per deleted site) and item 7's convergence. |
| 4 | `86ccdc099e` docs(changeset) | The `driver-memory` bullet names
the RLS no-guard path (review 5919688563, FAIL 1), and no longer says
that every seam hands the driver a lowered filter. Changeset text only.
|

13 files against `4d0b9cd542` (+662 / -102 at `e15606bae2`; commit 4
changes one changeset line). The changeset is
`.changeset/20822-f3-route-then-delete.md`: `patch` for
`@objectstack/metadata`, `@objectstack/objectql` and
`@objectstack/driver-memory`, with the (b) convergence stated.

## The answers that move, named

These were measured through the real engine (`ObjectQL` dist,
`engine.find`) on `SqlDriver` (`driver-sqlite-wasm`) and
`InMemoryDriver`. The table was synced through `driver.syncSchema`. The
object was either registered in the engine with its field map
("registered") or not registered ("unregistered").

- Rows: `r1` = `2026-07-28T00:00:00.000Z`, `r2` = `…T12:00:00.000Z`,
`r3` = `…T23:59:59.999Z`, `r4` = `2026-07-29T00:00:00.000Z`. The same
instant is written into `at` (`datetime`), `txt` (`text`) and `extra`
(not declared; memory only). `d` (`date`) holds the calendar day.
- Filter: `{ col: { $lte: '2026-07-28' } }`.
- Columns: BASE = `main` with commit 1 only; c2 = after commit 2; c3 =
after commit 3.

| Object · column | Driver | BASE | c2 | c3 |
|:--|:--|:--|:--|:--|
| registered · `at` (datetime) | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 |
| registered · `d` (date) | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 |
| registered · `txt` (text, ISO) | sqlite | none | none | none |
| registered · `txt` (text, ISO) | memory | r1,r2,r3 | r1,r2,r3 |
**none** |
| registered · `extra` (undeclared) | memory | r1,r2,r3 | r1,r2,r3 |
**none** |
| unregistered · `at` / `d` | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 |
| unregistered · `txt` | sqlite | none | **r1,r2,r3** | r1,r2,r3 |
| unregistered · `txt` / `extra` | memory | r1,r2,r3 | r1,r2,r3 |
r1,r2,r3 |
| any · `at` `$lte '9999-12-31'` | both | r1..r4 | r1..r4 | r1..r4 |
| any · `at` `$between` the day | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3
|

- **Commit 2 moves one answer, a widening, on `SqlDriver`.** Take an
unregistered object's non-datetime column that holds ISO instant text. A
bare-day `$lte` on it now keeps the whole day (none becomes r1,r2,r3).
That is item 7's reading for a seam that cannot read the declared type:
"applies the rewrite type-blind". The dispatch expected that
`SqlDriver`'s answer for an unregistered object would not move yet,
because its F1 copy still exists (H2). That holds for `datetime` and
`date` columns only. F1 covers the columns the driver itself knows as
`datetime`, and nothing else.
- **Commit 3 moves the (b) cells, both narrowings on `driver-memory`,
onto `SqlDriver`'s answer.** On a registered object:
  - a declared `text` column holding ISO text;
  - a column the object does not declare.

The typed seam leaves both byte-identical, and the deleted copy used to
widen them. The seat's answer calls this item 7's scope ("it is not a
decision"). It is declared in the changeset. An unregistered object does
**not** narrow on memory, because commit 2 now lowers it at the seam.
- Neither move is a narrowing beyond what item 7 names, so nothing
stopped.

## Commit 1: `queryHistory` becomes a seam (H1: held)

These were measured with a scratch probe over the built `dist` of
`@objectstack/metadata`, `driver-memory` and `driver-sqlite-wasm`. The
mode is driver mode (`new DatabaseLoader({ driver })`), with two saves
on one day and `until` / `since` = that day:

| State | memory `until` | memory `since = until` | sqlite `until` |
sqlite `since = until` |
|:--|:--|:--|:--|:--|
| `main` (F3 present) | 2 / 2 | 2 / 2 | 2 / 2 | 2 / 2 |
| all three commits | 2 / 2 | 2 / 2 | 2 / 2 | 2 / 2 |
| F3 deleted, loader lowering removed (dist ablation) | **0 / 0** | **0
/ 0** | 2 / 2 (F1 still present) | 2 / 2 |

- **Other direct driver callers in `packages/metadata`.** The other one
is `utils/history-cleanup.ts` (`recorded_at: { $lt: cutoffISO }`,
twice). That is an instant `$lt`, which no rule widens, so it is
unaffected. The other `_find` / `_count` filters in the loader are
equality only. No other temporal bound was found.
- **H4.** Group 2 (`driver-sql` F1) meets the same `queryHistory`
caller. Commit 1 lowers it for every driver, so **group 2 has no caller
left to route** in `packages/metadata`. §A below is the answer group 2
must keep once F1 is gone.
- **Pin:** `database-loader-20822-history-whole-day.test.ts`. It fakes
`Date` only.
  - §A: the rows on real SQLite in driver mode.
- §B: the `where` the driver's `find` / `count` receive (`recorded_at: {
$lt: next day }`, lower bound kept, instant `until` and other columns
byte-identical).

§B is driver-agnostic. It is the half that goes red when the loader
stops lowering.
- **Not pinned on memory inside `@objectstack/metadata`.** A new test
consumer of `@objectstack/driver-memory` needs a maintainer ruling
(`scripts/driver-memory-census.ledger.json`, `RULED_CEILING = 2`). So
the memory half is covered in two other ways:
- §B (what every driver receives), plus `driver-memory`'s own pin of how
it answers the lowered and the unlowered filter;
  - the dist measurement in the table above.
- Engine mode is untouched: the engine's `where` seam lowers it, typed
by the registered history object.

## Commit 2: an object with no field map is lowered type-blind (H2: held
for datetime and date, falsified for text)

The only change is `if (fields === null || typeof fields !== 'object')
return {};`. The typed branch is unchanged. The control in the new pin
(`engine-20822-no-field-map-type-blind-lowering.test.ts`) and the
existing `engine-shared-filter-lowering-seam.test.ts` stay green. The
new pin covers `find`, `findOne`, `count`, `aggregate`'s `where` and the
judge on an unregistered object. `having` has its own aggregated-row
reader (F8, group 3), which is untouched.

## Commit 3: F3 deleted (H3: held)

- **Deleted:**
  - the `$lte` and `$between` arms of the FilterCondition translator;
- the less-or-equal and `between` arms of the AST-node translator (`{
type: 'comparison' }`, which no seam emits; only direct callers reach
it).

`nextUtcCalendarDay` / `isUnboundedAbove` are no longer imported by
`memory-driver.ts`. The clobber-class table in `assembleLoweredWrites`'
docblock loses the `$lt` / `$ne` writers the rewrite added. No
driver-local guard is kept.
- **F3's typed reader** is the engine's `declaredDatetimeLowering`. It
is typed when the object has a field map, and type-blind without one
(commit 2). The driver's own `syncSchema` temporal index is not
consulted by any seam.
- **The 43 direct-call tests** are the same 43 that went red with the
deletion alone:

  | Suite | Tests |
  |:--|:--|
  | temporal-conformance | 25 |
  | calendar-day-upper-bound | 5 |
  | analytics-20661 | 5 |
  | datetime-storage | 4 |
  | temporal-storage-form | 2 |
  | shared-lowering-door | 2 |

Each now hands `find()` what a typed seam hands it:
`lowerFilterCondition` with a reader over the fixture's own declared
field map. In the 20661 file, the `undeclared` reading is lowered
type-blind, which is commit 2's reading. **0 `expect(` lines changed**
in the six routed files.
- **New pin:** `memory-driver-20822-comparison-as-written.test.ts`.
- §A (item 5): a direct call gets the comparison it wrote. There is one
cell per deleted site. On a `datetime` column a bare day takes its
storage form, the midnight instant, so `$lte` keeps the midnight row.
  - §B (item 7): the registered-object convergence cells.
  - §C: the type-blind reading.

## Ablations: each one committed first, restored and proven by blob
hash, re-run at the final head `e15606bae2`

Every mutation went through `scripts/ablation-replace.mjs` (anchor must
hit, blob verified, restored blob equal to HEAD, `git diff HEAD` empty).

| Commit | Mutation | Red | Green |
|:--|:--|:--|:--|
| 1 | loader lowering removed | 2 of 8 (§B's two bare-day cells) | §A
stays green through `SqlDriver`'s F1 copy |
| 2 | absent-map branch removed | 4 of 19 (the four unregistered
lowering cells) | the control, the instant and the judge cells |
| 3 | `$lte` arm restored (with its import) | 4 of 1424 | the other 1420
|
| 3 | `$between` arm restored | 2 of 1424 | the other 1422 |
| 3 | AST less-or-equal arm restored | 1 of 1424 | the other 1423 |
| 3 | AST `between` arm restored | 1 of 1424 | the other 1423 |

In every commit-3 row, the red cells are the matching cells of the new
pin and nothing else. Each restored copy is idempotent on lowered input
(item 9).

The H1 dist counterfactual (the memory 2 / 2 to 0 / 0 row above) ran
through `ablation-dist-preflight.mjs` for the restore leg: marker absent
from all 30 built files and the tree clean. The mutate leg's arrival in
`dist` is shown by the probe's answer moving.

## Tests, gates and lint, all at `e15606bae2`

- `@objectstack/driver-memory` vitest: 66 files / 1424 passed.
- `@objectstack/metadata` vitest: 56 files / 836 passed.
- `@objectstack/objectql` vitest `--project local`: 348 files / 6807
passed. `--project repo`: 1 / 5 passed.
- The three packages' `typecheck`: exit 0. That covers objectql's
`check:test-typecheck` (OK, 234 errors / 65 signatures held in the
ledger). driver-memory's `tsconfig.json` program lists all 66 test
files.
- `node scripts/pm/dispatch-gates.mjs --commands` (merge base
`4d0b9cd54`) derived 66 families. **66 run, all exit 0.** The `--ran`
verdict: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED
(a DERIVED zero …)". This includes:
- `check:driver-conformance`: "OK — 50 covered cell(s), 0 in the DEBT
ledger, 0 exempt".
- `check:driver-memory-census`: "OK — every declaration is ledgered …".
- `check:dual-build-cjs-loads` and `check:type-check-debt`, after a
whole-workspace build.
- `check:query-options-erasure`: back at 236 test sites. My first draft
added one `{ where } as any`, and it is now typed.
- **Lint, narrowed.** `eslint --no-inline-config --format json` over the
12 changed `.ts` files gave 12 files, 0 errors and 0 warnings. Each file
resolves under `--print-config`. `eslint.config.mjs` enables no
type-aware linting ("no `parserOptions.project`, no typed
`@typescript-eslint` rules"), so no untouched file's verdict can move.
The full `pnpm lint` is CI's.

## Acceptance notes

- **The RLS compile seam's no-guard reading.** This is noted, not filed.
`carrier:` objectstack-ai#20822 group 2 (`driver-sql` F1), which removes the next copy
standing behind this reading.
- `plugin-security` `rls-compiler.ts` `rlsLowering` reads an absent
guard as "no datetime column". The guard is absent when
`getObjectFieldNames` cannot resolve the object. This is the same
population, and the same reading, that commit 2 changed on the engine.
- Its existing pin says so: "a guard with no types reads no column as
datetime" gives `{ signed_on: { $lte: '2026-01-05' } }`.
- After this PR, an RLS `using` policy with a bare-day upper bound, on
an object whose declared fields the security plugin cannot resolve,
reaches `driver-memory` as written, where the deleted copy used to widen
it. (A `check` clause reaches `matchesFilterCondition`, not this driver,
so it does not move here.) The changeset's `driver-memory` bullet names
this path (`86ccdc099e`, after contract review 5919688563), and the
seat's answer 5918373748 (A) carries the `rlsLowering` twin into group
2.
- Item 5 covers a filter composed after the engine's seam. Item 7's
general rule would read that seam type-blind.
- Measured only at unit level (that pin and §A here), not through a
public door. It is outside this card's file surface.
- The metadata-side memory pin is replaced by §B plus the
`driver-memory` pins because of the census ledger (above).
- The branch was re-stacked twice before this PR opened, with
`--force-with-lease` and all five conditions met: first to fold two WIP
commits into commit 3, then onto `main` `4d0b9cd542` after objectstack-ai#20911 landed
in `engine.ts`. No merge commit remains.
- Not done here: objectstack-ai#20822 group 2 (F1, F2), group 3 (F6, F7, F8), and the
stale matcher pointers the last group PR corrects.

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

---------

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 protocol:data size/xl tests tooling

Projects

None yet

2 participants