Skip to content

feat(spec,objectql,metadata-protocol): grouped and aggregated queries honour search - #20487

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20358-aggregate-honours-search
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20358-aggregate-honours-search

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20358
Clause-②: yes

What this does

A grouped or aggregated query now honours ADR-0061 search. Route (A) from triage (5871414670):

  • @objectstack/spec (packages/spec/src/data/data-engine.zod.ts): EngineAggregateOptionsSchema declares search and searchFields with the same Zod expression as EngineQueryOptionsSchema (z.union([z.string(), FullTextSearchSchema]), z.array(z.string())). The structured search arm carries flag defaults, so the aggregate options now have two shapes. ADR-0122 and check:spec-parsed-alias therefore require EngineAggregateOptionsParsed. It is declared, and its isomorphism pin leaves type-alias-convention.pin.test.ts (780 to 779). That is the one added export: check:api-surface reports 0 breaking, 1 added.
  • @objectstack/objectql (packages/objectql/src/engine.ts): ENGINE_AGGREGATE_OPTION_KEYS gains the two keys. aggregate() runs them through the existing expandSearchOnAst, via a small carrier helper (expandSearchOnAggregateOptions), and does not add a second expander. The expansion sits at the same point in the sequence as on find: after the where doors (lowerWhereFilterArray), and before the AST is built, tokens resolve and the security middlewares run. The helper returns a copy of the caller's bag, so the bag is never written through.
  • @objectstack/metadata-protocol (packages/metadata-protocol/src/protocol.ts): findData's grouped branch passes search / searchFields to engine.aggregate. searchFields was already validated before the branch fork (the INVALID_FIELD gate), so both branches refuse the same bad override.

Mechanism hypotheses, measured:

  • ① Only EngineAggregateOptionsSchema is read. DataEngineAggregateOptionsSchema (the deprecated legacy schema) has no runtime reader: git grep finds it only in packages/spec tests and the type-alias pin. It is left unwidened.
  • ② The grouped branch site held as described.
  • ③ At base, the engine refused search on aggregate (rejectUnknownEngineOptions), confirmed.
  • ④ The card's before-state reproduced at the public door (below).

Landing site beyond the three declared files: the public-door pin went into packages/rest/src/list-view-grouping-query-door.test.ts as a new §10. That file already drives the card's 186-row fixture through the real RestServer → findData → ObjectQL.aggregate → sqlite SqlDriver chain on both aggregate tiers. The pin is test-only, and no second harness was written.

Before / after at POST /api/v1/data/:object/query

Before (reproduced by reverting only the protocol pass-through, which is the base's door behaviour; the engine change is inert when the key is not sent): { groupBy: ['business_unit'], aggregations: [count], search: 'harbour' } answered five groups, northgate_operations 86 / northgate_quality 61 / riverside_plant 31 / northgate_plant 7 / harbour_office 1, the unsearched answer. On both tiers, the flat { search: 'harbour' } returned one row.

After: [{ business_unit: 'harbour_office', count: 1 }], total: 1, on both tiers.

Tests

Head of this PR: 2196566d3f. The full suites ran on 3576fd34e7 (this branch with origin/main acd009521e merged in). The two commits after it change only packages/objectql/src/engine-aggregate-search.test.ts: the find double now applies the caller's limit (check:objectql-double-limit), and three as any erasures became typed calls (check:query-options-erasure). That file and objectql's typecheck were re-run on 2196566d3f. All vitest runs used --maxWorkers=2.

package run result
@objectstack/objectql vitest run --project local 328 files, 6084 passed
@objectstack/objectql vitest run --project repo 5 passed
@objectstack/objectql typecheck (tsc + scripts + test layer) exit 0, re-run on 2196566d3f
@objectstack/objectql src/engine-aggregate-search.test.ts on 2196566d3f 17 passed
@objectstack/metadata-protocol vitest run 189 files (+3 skipped), 2750 passed, 19 skipped
@objectstack/metadata-protocol typecheck exit 0
@objectstack/rest vitest run --project local 216 files, 3923 passed, 26 skipped
@objectstack/rest vitest run --project repo 8 passed
@objectstack/rest typecheck (tsc + test layer) exit 0
@objectstack/spec vitest run --project local 568 files, 16692 passed, 1 todo
@objectstack/spec vitest run --project repo 690 passed
@objectstack/spec typecheck (tsc + scripts + test layer) exit 0

The filter direction is the changed packages themselves. @objectstack/rest is included because it hosts the public-door pin and its route tests cover grouped queries. The downstream consumers of the widened EngineAggregateOptions type are covered by the ones that compile against it here (objectql, metadata-protocol and rest typecheck); check:api-surface shows 0 removed or narrowed.

New pins:

  • packages/rest/src/list-view-grouping-query-door.test.ts §10, the public door, both aggregate tiers (driver-sql native GROUP BY, and the in-memory lowering). Each tier assertion checks which face served the query.
    • The measured case: search: 'harbour' returns the ONE group, total: 1, and equals the grouping of the flat searched rows. The in-memory tier's rows read carries $icontains and no search key.
    • search: 'northgate': every header number, sum_amount included, equals the grouping of the flat searched rows.
    • searchFields: ['owner'] narrows the answer to the owner_3 rows. The oracle is the fixture generator, not the door. The one-row unit drops out, and the narrowed answer differs from the default-field answer.
    • search + having: search drops riverside_plant, and having drops northgate_plant.
    • aggregations with no groupBy: 154, where the unsearched control gives 186.
    • A grouped query with an unscannable searchFields is 400 / INVALID_FIELD.
  • packages/objectql/src/engine-aggregate-search.test.ts, both tiers:
    • the grouped answer equals the grouping of find()'s searched rows (default fields, narrowed, structured form, AND-ed with where, multi-term);
    • one expander: the where the driver receives from aggregate deep-equals the one find sends for the same bag, and neither AST carries search / searchFields;
    • the aggregate over a search equals the aggregate over the same filter written as where;
    • no-groupBy aggregations, search + having, and the caller's frozen bag is not written through;
    • $search / $searchFields are still refused on aggregate, as unknown options;
    • drift pin: a proof table must cover ENGINE_OPTION_KEY_SETS.aggregate exactly, and every legal key must have an observable effect. This is the question findOne's drift pin asks, now asked of this verb.
  • packages/spec/src/data/data-engine.test.ts: a parse keeps both keys in both search forms, and the aggregate options refuse the values the query options refuse. The two keys' input JSON Schemas deep-equal EngineQueryOptionsSchema's (one declaration on both verbs).
  • The existing drift pin engine-unknown-option.test.ts (each legal set equals its schema's shape) holds unchanged. So does the unknown-key refusal pin for aggregate ('bogus'), byte for byte.

Reverse verification (one-shot, not left in the tree)

Both ablations committed the fix first, then mutated through scripts/ablation-replace.mjs (literal anchor, on-disk counts and blob hashes). Each armed an absolute-path restore trap, and each restore was proven by blob equality with HEAD and an empty git diff HEAD.

  • A: protocol pass-through removed (the base's door behaviour), pinned through dist/.
    • Mutation: the anchor search: options.search, + searchFields: options.searchFields, was deleted, taking the blob from 508f88c8c8e0 to f8a47dbaf791. @objectstack/metadata-protocol was rebuilt, and ablation-dist-preflight --absent confirmed the marker was gone from all 24 built files.
    • list-view-grouping-query-door.test.ts: 10 failed / 34 passed. Every positive §10 case failed on both tiers, in the predicted direction: the measured case received the five unsearched groups, 86/61/31/7/1. The searchFields refusal case stayed green, because that gate sits before the branch fork.
    • Restore: rebuilt, the marker was present again in both dist chunks, the tree was clean, and the file returned to 44 passed.
  • B: engine expansion call removed (the keys stay legal but are never executed), in source (objectql tests import ./engine.js).
    • engine-aggregate-search.test.ts: 13 failed / 4 passed. The 12 behavioural cases failed across both tiers, and so did the drift pin, on its search proof, which is the declared-but-unexecuted shape it exists to catch. The 4 that stayed green were the frozen-bag case and the two $search refusals, none of which depend on the expansion.
    • Restore: blob 177d256d61d4 equals HEAD, and git diff HEAD is empty.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 114 commands. All 114 were run on 2196566d3f, each exit captured before any pipe and reconciled with --ran: 114 derived, 112 run, 2 NOT-MEASURED, 0 UNRUN. 111 exited 0, including check:spec-parsed-alias, check:api-surface, check:authorable-surface, check:docs, check:export-origins, check:generated, check:liveness, check:query-options-erasure, check:objectql-double-limit, check:where-matcher, check:engine-double-contract, check:adr-0087-registration, check:changeset-no-major and check:nul-bytes.

check:skill-examples refused its prerequisite on the first run (client-react not built). It was run after building @objectstack/client-react..., and 259 prose examples type-check (exit 0).

  • NOT MEASURED: check:dual-build-cjs-loads, reason: exit 3, PREREQUISITE NOT MET (it needs every package's dist/; CI builds them).
  • NOT MEASURED: check:type-check-debt, reason: my 420 s timeout fired mid re-measure (exit 124). It needs the whole ./packages closure built, which lint.yml does first.
  • NOT MEASURED: check-engine-split-ratio --days 90, reason: the clone is shallow and the gate refused (exit 2) rather than compute over a truncated window.

Spec artefacts, regenerated only where check:generated proved them stale:

  • api-surface/data.json (+EngineAggregateOptionsParsed: 0 breaking, 1 added);
  • export-origins/data.json;
  • content/docs/references/data/data-engine.mdx;
  • authorable-surface/data.json (+2 rows, written by the spec build's gen:schema).

After the merge of origin/main, check:generated reported all 15 artifacts up to date against a freshly built packages/spec/dist.

Acceptance notes

  • Published skill row now understates the aggregate key set, and is not edited here. In skills/objectstack-query/SKILL.md, the Calling Convention table lists engine aggregate's legal keys as context, where, groupBy, aggregations, having, timezone, and after this PR the closed set also holds search and searchFields. The row understates the set: nothing it names is refused, but a reader would not know search works. skills/** is a Tier H governed surface and outside this card's declared file surface, so the one-row edit (0 net lines) is left for the seat to route. It is raised in the dev report.
  • findData's grouped branch still spells the bag as any. Every key in it is now declared, so the erasure could go. Dropping it moves protocol.ts's count in the shrink-only scripts/query-options-erasure-baseline.json (6 to 5), which is outside this card's surface. Carrier: whoever next touches that branch.
  • The engine's unknown-option refusal is a bare Error, with no ADR-0112 code / status. This is pre-existing and unchanged byte for byte. findData never builds the aggregate bag from caller keys, so the refusal is not reachable from POST /data/:object/query, and it stays pinned by message as before.
  • engine.count still takes no search. So the flat branch under search + limit still reports a page-local total estimate, which is documented behaviour and unchanged here.
  • objectui follow-up (triage note 3). The console's workaround, which hands grouping back to the page window while a toolbar search is active, can end once a release carries this. compileListViewGroupQuery does not carry search itself; a client spreads search onto the compiled body, as §10 does. That card is the accepting seat's to file, with Blocked-by: on this one.
  • DataEngineAggregateOptionsSchema is deliberately not widened. It is the deprecated legacy aggregate schema, with no runtime reader (measured by git grep: packages/spec tests and the type-alias pin only).

Generated by Claude Code

…uped answers under a search group the searched rows

EngineAggregateOptionsSchema declares search / searchFields exactly as
EngineQueryOptionsSchema does; ObjectQL.aggregate accepts them and runs the
one ADR-0061 expander find runs; findData's grouped branch passes them
through. Pinned at the public door (POST /data/:object/query) on both
aggregate tiers.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…, every declared key executed

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…ed a second shape with search (ADR-0122)

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…eference for the aggregate search keys

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…cases instead of erasing them

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…gregate-honours-search

# Conflicts:
#	packages/spec/src/type-alias-convention.pin.test.ts
…igins from the merged tree

The os-regen driver kept the branch's side of all three on the merge of
origin/main b810ddb, dropping main's rows (the empty-operator exports,
among others). Regenerated after a fresh spec build; the result differs from
origin/main by exactly this PR's rows: EngineAggregateOptionsParsed (api-surface,
export-origins) and EngineAggregateOptions:search / :searchFields
(authorable-surface).

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/spec, touching 13 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/authorable-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 9801da1234761c14eb88663635792723e41d359f.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/authorable-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 70 pages)
  • 1 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 — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9801da1234761c14eb88663635792723e41d359f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9801da1234761c14eb88663635792723e41d359f

⚠️ 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 9801da1234761c14eb88663635792723e41d359f → 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: 2196566d3fb74d497a2c2ca9dc3cc65a8a82d4ff
Local-runs: none

Inputs, and nothing else: card #20358 (body and every comment — the filing and triage 5862519690, the retriage request 5868769184, the retriage answer 5871414670 (route A, re-routed to domain:spec), the claim 5872209176, the os-dev-report 5875170281); PR #20487 (body, comments: none, reviews: none, file list: 12 files, +668/-5) and the net diff origin/main...refs/os-seat2/pr20487 at merge base acd009521e; the check-runs on the head. Read-only throughout: git object reads and REST GETs; nothing built, run or re-run.

Check-runs on this head: none exist. The check-runs listing for 2196566d3f answers total_count: 0; the workflow-runs listing by head_sha answers 0 and by branch claude/issue-20358-aggregate-honours-search answers 0; the five check-suites (vercel, fly-io, claude, cloudflare, objectstack-fleet) are queued with 0 runs; the only commit status is Vercel success (advisory). Cause, read from the workflow files at the head: lint.yml and ci.yml trigger on pull_request and merge_group (push only on main), and the PR is mergeable_state: dirty, for which the platform queues no pull_request run. So not one of the seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard) is concluded on this head, and none is presumed green here. This record judges the diff; the gate verdicts are owed by the merge round's head, where the seat's mechanical delta check and the enqueue pre-check (every check green) apply. Per the brief, the conflict itself (packages/spec/src/type-alias-convention.pin.test.ts plus three generated spec artefacts) is not a FAIL reason.

① Derived judgments

Accept-set changes.

  1. EngineAggregateOptionsSchema (packages/spec/src/data/data-engine.zod.ts) gains search: z.union([z.string(), FullTextSearchSchema]).optional() and searchFields: z.array(z.string()).optional() — the same Zod expressions as EngineQueryOptionsSchema (lines 142 and 151), pinned by the new spec test's per-key input JSON Schema deep-equal. RIGHT: route (A) exactly as triage 5871414670 named it; the schema is non-strict, so a parse used to strip both keys.
  2. ENGINE_AGGREGATE_OPTION_KEYS (packages/objectql/src/engine.ts) gains search and searchFields; ENGINE_OPTION_KEY_SETS.aggregate follows by reference. RIGHT: a key rejectUnknownEngineOptions refused at base is now accepted AND executed. The existing drift pin engine-unknown-option.test.ts (each legal set equals its schema's shape minus tombstones) holds by construction and is not in the diff; the new proof-table pin requires every legal aggregate key to have an observable driver effect. $search and $searchFields stay refused on aggregate (pinned, with the refusal text naming the two new legal keys).
  3. ctx.api.object(name).aggregate() (ObjectQLObject.aggregate, engine.ts ~17400) spreads the caller's bag into engine.aggregate, so it inherits the widening without an edit. RIGHT, and the changeset names it.
  4. IMPLICIT, and not named by the dev: DataEngineAggregateRequestSchema.query is EngineAggregateOptionsSchema.extend(RpcLegacyFilterMixin) (data-engine.zod.ts:1308), so the RPC envelope's aggregate arm — and DataEngineRequestSchema's union through it — now accepts search / searchFields. The regenerated content/docs/references/data/data-engine.mdx shows it: the DataEngineAggregateRequest.query and DataEngineRequest option tables gained the two rows beside filter. Judged RIGHT and harmless: a whole-tree git grep at the head finds no runtime reader of DataEngineAggregateRequest* or DataEngineRequestSchema outside packages/spec; the api-surface entry is the same const; the widening is the same two optional keys from one declaration. Recorded so the derived surface is named.
  5. DataEngineAggregateOptionsSchema (legacy, deprecated) is left unwidened. VERIFIED from the code, not from the dev's prose: it is composed from BaseEngineOptionsSchema.extend({ filter, groupBy, aggregations with method }) independently of the widened schema, and a whole-tree git grep at the head finds only its declaration, its z.input alias, the spec generated artefacts (declaration-map, json-schema manifest, authorable-surface base, dropped-refinements baseline), one sentence in content/docs/kernel/contracts/data-engine.mdx, and its isomorphism pin — no runtime reader. Mechanism hypothesis 1 holds; its iso pin staying (pin file line 785) is consistent, since its input still equals its infer.
  6. The other runtime callers of engine.aggregate (packages/mcp/src/stdio-data-bridge.ts:439, packages/services/service-analytics/src/plugin.ts:346, packages/runtime/src/action-execution.ts:360, packages/objectql/src/summary-aggregate.ts:111) build their bags from named keys or hand-declared slices (McpDataBridge.aggregate declares where, groupBy, aggregations, timezone; the MCP tool input is a hand-authored z.object with its own alias table), so the widening reaches none of them and nothing new is dropped at a second door. The MCP aggregate tool offering no search is pre-existing and not this card's.

Public-surface changes.

  1. New export EngineAggregateOptionsParsed (z.infer of the schema); api-surface/data.json +1 (0 breaking, 1 added), export-origins/data.json +1. RIGHT and forced: ADR-0122 rule 2 (scripts/check-spec-parsed-alias.mjs) requires a bare z.input alias's schema to name its parsed state or be tsc-pinned isomorphic, and FullTextSearchSchema carries .default() flags (fuzzy, operator, highlight), so input no longer equals infer — the same reason EngineQueryOptionsParsed exists. The pin Iso_data_dataEngine__EngineAggregateOptionsSchema leaves and the count moves 780 to 779 in all three spellings the file carries (section header, test title, toHaveLength), with the runtime companion recomputing it from source.
  2. authorable-surface/data.json +2 rows (data/EngineAggregateOptions:search, data/EngineAggregateOptions:searchFields). RIGHT: written by the spec build's gen:schema, matching the two keys.
  3. content/docs/references/data/data-engine.mdx regenerated (+19) — an AUTO-GEN tree, not hand-edited. RIGHT.

Security ordering, answered from the code on both verbs.

  1. find runs expandSearchOnAst at engine.ts:11246, before executeWithMiddleware at ~11313. aggregate runs expandSearchOnAggregateOptions at :16247 — after foldEngineOptionAliases, rejectUnknownEngineOptions and lowerWhereFilterArray; before rejectCredentialAggregation (which reads only aggregations[].field); before the having doors; before the AST is built at :16463 (where: query.where reads the reassigned, expanded bag); before resolveWhereTokens; and before executeWithMiddleware at :16514. The same point in the sequence as find. RIGHT.
  2. FLS oracle: the security plugin's step 2.9 predicate guard (packages/plugins/plugin-security/src/security-plugin.ts ~3572 to 3611, assertReadableQueryFields from predicate-guard.ts) runs on opCtx.ast for every read operation (the options.where substitution applies to update and delete only), and collectQueryFields walks ast.where, having, orderBy, groupBy, aggregations[].field and aggregations[].filter. Because the search expansion has already been ANDed into ast.where as { field: { $icontains } } clauses on BOTH verbs, a searchFields naming an FLS-hidden or partially masked field — or a default searchable field hidden for this caller — is refused 403 field_predicate_denied before RLS injection, identically on find and aggregate. No grouped-count oracle opens that find does not already close. RLS and sharing injection (step 3, and the read-depth branch that lists aggregate beside find, findOne and count) runs after, composing { $and: [caller where including search, rls] } the same way on both verbs — ADR-0061 D5 (no separate unguarded search path) holds. RIGHT. The searchable set is server-resolved by resolveSearchFields in @objectstack/spec/data (declared searchableFields or auto-default; secret, encrypted and PII fields excluded; an override intersected, never widened) and consults no caller permission — one rule on both verbs — and the protocol's assertSearchFieldsAreSearchable gate sits above the branch fork (protocol.ts ~10937), so an unscannable override is 400 INVALID_FIELD on both branches (pinned in rest §10).

One expander, both tiers.

  1. expandSearchOnAggregateOptions builds a carrier { object, where, search, searchFields } and calls the same private expandSearchOnAst with this._registry.getObject(object) — the schema find passes as _findSchema — so field resolution, the searchFields over search.fields precedence, the term-AND / field-OR shape, the enum label-to-value mapping and the pinyin companion clause are one code path; it returns a copy with the two keys gone (the caller's frozen bag is pinned untouched) and returns the same reference when neither key is present. Both aggregate tiers read the post-middleware ast: native drv.aggregate(object, ast, ...), and the in-memory lowering's driver.find(object, rowsAst) where rowsAst is ast minus groupBy, aggregations and having — where rides through on both. The objectql pin deep-equals the driver-received where of aggregate against find's for four bags on both tiers; the rest door pin checks the in-memory tier's rows read carries $icontains and no search key. RIGHT — no second expander.

Protocol.

  1. findData's grouped branch passes search: options.search and searchFields: options.searchFields, read after the $search / $searchFields alias consumption and after the searchable-fields gate, so both branches read the same normalised values; the flat branch is untouched; engine.count still takes no search (documented, unchanged). RIGHT. The bag stays as any — see ③.

Tests and the sibling. The rest door §10 (11 cases, both tiers with the serving tier asserted, flat-parity and unsearched controls, searchFields narrowing with the fixture generator as oracle, search with having, no-groupBy, the INVALID_FIELD refusal), the objectql file (17: parity, one-expander deep-equal, where-equivalence, frozen bag, $search refusal, the proof-table drift pin over ENGINE_OPTION_KEY_SETS.aggregate) and the spec file (3) pin the mechanism at the door, the engine and the declaration. The two ablations in the PR body turn red in the predicted direction (A: door 10 failed / 34 passed; B: engine 13 failed / 4 passed, the drift pin included) — the dev's readings, cited here and not re-run. objectui at the pinned sha: packages/plugin-grid/src/useServerGrouping.ts:311 deliberately strips $search / $searchFields off the header query (the workaround the card names), and packages/types/src/data.ts and packages/data-objectstack/src/index.ts consume EngineAggregateOptions as a type — two added optional keys are source-compatible, so the sibling is unaffected.

② Semver level

.changeset/20358-aggregate-honours-search.md grades @objectstack/spec minor, @objectstack/objectql minor, @objectstack/metadata-protocol patch; the PR body carries Clause-②: yes and the changeset body Clause-②: yes (widening).

  • Clause-②: yes (widening) is RIGHT: two published accept sets (EngineAggregateOptionsSchema; the engine's ENGINE_AGGREGATE_OPTION_KEYS) admit two keys they refused or stripped before, nothing previously admitted is refused, nothing is renamed or retired, and one public export is added. (widening) is the only legal arm for that direction; not (narrowing), not breaking, so no ADR-0087 disposition marker is owed (check-adr-0087-registration: a non-breaking changeset is not its business) and "Nothing to migrate" is true.
  • @objectstack/spec minor — RIGHT: a purely additive widening of a published schema plus a new export takes at least minor (the WHICH LEVEL ruling mechanised in check-changeset-no-major.mjs).
  • @objectstack/objectql minor — RIGHT: its accept set widens, reachable through engine.aggregate and ctx.api.object().aggregate().
  • @objectstack/metadata-protocol patch — RIGHT: its wire accept set does not change (QuerySchema.search was already accepted on POST /data/:object/query; FindDataRequestSchema is untouched); what changes is that the grouped branch now honours a key it accepted — a bug fix in a released package, patch, never skip-changeset. The level gate is PR-scoped ("at least one package whose src/** the diff moves at minor or above") and is satisfied twice.
  • skip-changeset absent — RIGHT.

③ Boundary flags

Dev deviations (os-dev-report 5875170281, six):

  1. Landing site beyond the three declared source files: packages/rest/src/list-view-grouping-query-door.test.ts §10, test only, in the file that already drives the card's 186-row fixture through RestServer, findData, ObjectQL.aggregate and sqlite on both tiers. ACCEPTED — triage execution note 2 landed without a second harness.
  2. Extra export EngineAggregateOptionsParsed and the iso-pin removal (780 to 779). ACCEPTED — forced by ADR-0122 rule 2, inside the owned file surface (① item 7).
  3. origin/main merged into the branch before the full-suite run (merge commit 3576fd34e7). ACCEPTED as process; the head is dirty against current main again, which is the merge round now running.
  4. authorable-surface/data.json +2 rows written by the spec build's gen:schema. ACCEPTED — mechanical, matches the keys (① item 8).
  5. Commit trailers in AGENTS.md's model-free form and the session-URL PR footer, not the harness reminder's model-named trailer. ACCEPTED — AGENTS.md is the source of truth, and the reminder itself yields to a repo rule.
  6. Stopped its own queued runner (its own process group) to merge main first. Not a diff concern; noted.

open_questions[0] — the skills/objectstack-query/SKILL.md Calling Convention row (engine aggregate: context, where, groupBy, aggregations, having, timezone; "a key outside the row is refused by name"). The seat's answer — route A, a separate skills-lane card after landing, the row stays out of this PR — STANDS, judged from the code:

  • Nothing in this diff makes the sentence false BEFORE landing: the row describes the engine main and the published packages have, and at base rejectUnknownEngineOptions still refuses search on aggregate (mechanism hypothesis 3, and the untouched 'bogus' refusal pin).
  • After landing on main the row UNDERSTATES: search / searchFields become legal and executed on aggregate, so "a key outside the row is refused by name" is false for those two keys. It never causes a refused call; the harm is an agent that believes the row and groups a page of searched rows on the client — the workaround this card retires.
  • No gate pins the row against ENGINE_OPTION_KEY_SETS: scripts/check-corpus-claim-drift.mjs cites SKILL.md:154 (the SQL / NoSQL column header) only, and packages/spec/scripts/skill-map-guards.test.ts nothing — the staleness is silent, not a red gate. The diff touches no governed surface (Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14 register: no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md), so keeping the row out leaves this PR on its default landing path; this record is the at-tier clause-② review the claim owed.
  • Sequencing note for the seat, not an escalation: skills/ ships to customer projects (the create-objectstack scaffold copies it), so a release that consumes this changeset before the row lands publishes a skill that contradicts the published engine for that release. Land the row before the next cut when practical — one table row, and it understates rather than refuses.

out_of_scope_findings (three):

  • The as any on the grouped-branch aggregate bag could go now that every key in it is declared (scripts/query-options-erasure-baseline.json, shrink-only, protocol.ts 6 to 5). AGREED out of scope: the diff adds two keys inside the existing erasure without moving the count; carrier: whoever next touches that branch.
  • rejectUnknownEngineOptions throws a bare Error without an ADR-0112 code. AGREED pre-existing and byte-unchanged; unreachable from POST /data/:object/query for aggregate because findData builds the bag from named keys.
  • The objectui follow-up ending the page-window grouping workaround under a toolbar search (useServerGrouping.ts:311), Blocked-by: this card — the accepting seat's to file (triage note 3).

Additional flags raised by this review, none blocking:

  • ① item 4 (the RPC envelope's implicit widening) is named because the dev's mechanism hypothesis 1 measured the legacy schema's readers only. No action needed.
  • No security pin accompanies the change (a search over an FLS-hidden field on aggregate answering 403). Not a defect: the guard's coverage is by construction (opCtx.ast on every read verb) and the diff does not touch it; a pin is a hardening the next security-lane touch may add.
  • Check-runs: none on this head (above). A precondition for enqueue, not a verdict input.
  • The PR is a draft with os-tesla assigned, no comments and no reviews at the reading.

Implemented-by: claude/issue-20358-aggregate-honours-search
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

Merged via the queue into main with commit 1c1b8c8 Sep 28, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20358-aggregate-honours-search branch September 28, 2026 19:42
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…its that decided them (stage 3) (objectstack-ai#20533)

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

## What changed

This is stage 3 of the staged sweep. It covers
`packages/spec/src/data/**` and nothing else. It leaves out the files an
open PR or an in-flight claim holds: `data-engine.zod.ts`,
`data-engine.test.ts`, `hook.form.ts`, `analytics*.ts`,
`cube-member-inner-name-retirement.test.ts`, `driver/turso.zod.ts` and
`filter-subtree-provenance.ts`, as the claim names them. It also leaves
out four files that open PRs started editing after the claim:
`driver/turso.test.ts` (PR objectstack-ai#20504, objectstack-ai#20437's, opened 2026-09-28T20:08Z),
`object.form.ts` (PR objectstack-ai#20519, objectstack-ai#20432's, 21:55Z), `object.zod.ts` (PR
objectstack-ai#20521, objectstack-ai#20494's, 22:10Z) and `filter-logic-conformance.ts` (PR objectstack-ai#20523,
objectstack-ai#20444's, 22:39Z). See Acceptance notes. Later stages cover the other
areas, so this PR says `Part of`.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123). That is **163 sites on 161 lines in 44 files,
covering 40 numbers**. Each rewritten line now cites the commit in
`origin/main` history that decided what the line describes, and it says
in its own words what that commit decided.

No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/`
records the decision behind any of the 43 dead numbers in scope.
ADR-0104 names objectstack-ai#12380 only as a reference, and ADR-0055 states the rule
that objectstack-ai#8772's ruling enforced, not the ruling itself. So every anchor is
a commit: **38 distinct shas**. One number was dropped rather than
anchored: objectstack-ai#17286, a tracking card that recorded an axis as undecided,
under which no commit landed. The sentence keeps its reason in words.

Three comment sites in scope are left on purpose (see Acceptance notes).
Two are the `[objectstack-ai#6259]` marker in `api-derivation.ts:163`, which a test
string reads, and the test comment that names that marker. The third is
`field.zod.ts:370`, whose `objectstack-ai#6111` is objectui's number.

Only comments changed. Every source file keeps its line count (174 lines
out, 174 in, over 45 files), so no line citation into these files moves.
Thirteen of those 174 lines held no dead citation. Eleven are the other
half of a sentence that had to be reflowed or rewritten. One is a table
header (`value-roundtrip-conformance.ts:20`, 「card」 to 「card or commit」,
because its row now holds a commit). One is `api-derivation.ts:164`,
which now carries the `[objectstack-ai#6259]` sentence's commit. No code token moves
(see the guard below). The 41 string-literal sites that carry a dead
number are tokens, so they are left as they were and listed below.

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces. No PR number stands on an added
line.

Two more kinds of file change, both mechanical:
- **One regenerated reference page.** Two of the rewritten docblock
lines (`feed.zod.ts:15`, `:18`) project into
`content/docs/references/data/feed.mdx`. `check:docs` proved that page
stale, and `pnpm --filter @objectstack/spec check:generated --fix`
regenerated only it. The diff is two lines, each the same substitution
as its source line. No page a held file projects into (`analytics.mdx`,
`data-engine.mdx`, `hook.mdx`, `driver-turso.mdx`) moved.
- **A `patch` changeset** for `@objectstack/spec` (see Changeset below).

## Census: `data/`, before and after

**Instrument.** This is the instrument of stages 1 and 2. It sends REST
`GET /repos/objectstack-ai/objectstack/issues/N` without following
redirects, for every distinct number cited in `packages/spec/src/data`.
The population is:
- the citation gate's own exported `CITATION_RE` and
`NON_CITATION_HEADS`, kept when the qualifier is none, `objectstack`,
`objectstack-ai/objectstack`, `framework`, `pre-` or `post-`;
- widened here to the capitalised spellings of those qualifiers (`Pre-`,
`POST-`, `Framework`: 7 sites, one of them dead), which stage 2's
case-sensitive set did not read;
- N of 100 or more, excluding `summon` heads.

Each site is classified by the TypeScript parser as a line comment, a
docblock, a block comment or a string.

**Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The
dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at
the start, after every 100 numbers and at the end. They read 24 of 24
lit (200) and 24 of 24 dead (404) over 8 checkpoints in both runs.

| reading | tree | numbers probed | 200 | 404 | 301 or other | dead
sites, all of `data/` | in scope | excluded (held files) | in-scope
lines | in-scope files | dead numbers in scope |
|---|---|---|---|---|---|---|---|---|---|---|---|
| before | base `9bf5e67af`, probed 2026-09-28T19:32Z to 19:36Z | 618 |
571 | 47 | 0 | **240** | 207 | 33 | 204 | 47 | 43 |
| after | head `96fd49caa2`, probed 2026-09-28T23:19Z to 23:23Z | 600 |
571 | 29 | 0 | **77** | 44 | 33 | 43 | 16 | 21 |

**Before, in scope, by class.** 92 non-test docblock sites and 13
non-test line comments. 16 test docblock sites and 45 test line
comments. 39 test string sites. 2 non-test string sites.

**After, in scope.** 41 string sites and 3 comment sites remain, all
three deliberate. The head probe found no number newly dead since the
base probe: the same 571 numbers answer 200.

PR objectstack-ai#20226's area table read `data` 239 at an earlier base; this census
reads 240 at `9bf5e67af`. The 33 excluded sites sit in `object.zod.ts`
(15), `analytics.zod.ts` (3), `analytics-strictness-batchd.test.ts` (2),
`analytics-date-range-two-bound-window.test.ts` (1),
`driver/turso.zod.ts` (2), `driver/turso.test.ts` (3),
`filter-subtree-provenance.ts` (3), `filter-logic-conformance.ts` (3)
and `object.form.ts` (1). `data-engine.*` and `hook.form.ts` carry none.

## Per-number table

The counts are in-scope sites and files at the base. `rewritten / left`
gives comment sites rewritten and sites left. Every anchor was read in
its diff or message, not only in its subject: it is the commit that made
the change the line now describes, and its own diff or message names the
number it replaces.

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#6111` (objectui) | 1/1 | 0/1 | objectui's number, left: see
Acceptance notes |
| `objectstack-ai#6259` | 5/2 | 1/4 | `6968885ef`: retires the producer-less `batch:
'bulk'` row of `DATA_ACTION_TO_API_OPERATION` and the prose calling
`batch` a runtime action. The marker and 2 test strings stay (see
Acceptance notes) |
| `objectstack-ai#6345` | 18/5 | 17/1 | `e2798fab7`: one driver vocabulary; both boot
hosts read the shared table; `mongo` to `mongodb`; turso a builtin; the
fork-1 and fork-2 refusals |
| `objectstack-ai#6571` | 10/2 | 8/2 | `2f3e79351`: `$between` endpoints accept the
ISO/clock strings the platform produces, as a bare string (rider ①) |
| `objectstack-ai#8495` | 9/2 | 6/3 | `4bfe1a539`: refuses `${…}` placeholders in
memory `persistence.path` / `persistence.key` at publish |
| `objectstack-ai#8656` | 1/1 | 0/1 | a test title only |
| `objectstack-ai#8696` | 20/8 | 17/3 | `90a12fb18`, the card's mongodb arm: a bound
secret rides beside an unmodified url as MongoClient `auth`. Its own
pins carry the multi-host form `new URL()` cannot parse and the bound
secret outranking `options.auth` |
| `objectstack-ai#8772` | 3/2 | 3/0 | `75b7c240a`: Direction 2 of the 2026-08-16
maintainer ruling. The builder forces `required: true` on a
`master_detail` under `controlled_by_parent`, and raw parse stays
tolerant. ADR-0055 stays cited beside it |
| `objectstack-ai#8778` | 1/1 | 1/0 | `7901b2dd2`: stamp-only
`tenancy.organizationField`, declared by `sys_api_key` |
| `objectstack-ai#8794` | 2/1 | 2/0 | `1850ebbb0`: corrects the reuse-safety claim on
the filter-subtree mark from the survey's measurement, and routes a
mechanism change to a spec-seat ruling (stage 1's anchor too) |
| `objectstack-ai#8836` | 2/1 | 2/0 | `1850ebbb0`: the same commit, which pins the
invariant (one line carries both numbers) |
| `objectstack-ai#8873` | 6/3 | 6/0 | `096106522`: a bound `credentialsRef` reaches
the postgres server on the DSN branch. Its diff records that `pg` sends
a password only when the server asks |
| `objectstack-ai#8874` | 1/1 | 1/0 | `d70428ae7`: a declared mysql `ssl` reaches
`mysql2` as its own TLS options object, because `mysql2` rejects a bare
boolean |
| `objectstack-ai#8876` | 9/5 | 6/3 | `d634e665b`: exports `urlUserinfoUsername`, and
its diff states the asymmetry that a username is not credential material
|
| `objectstack-ai#9040` | 20/6 | 14/6 | `24206416a`: refuses a credential in the mongo
options passthrough at publish, and redacts the passthrough secret paths
on read |
| `objectstack-ai#9041` | 22/2 | 17/5 | `d491625c1`: refuses a bound `credentialsRef`
with a user-less mongo `config.url`, with the triage's fences |
| `objectstack-ai#10165` | 5/1 | 1/4 | `801296050`: `ttl.onlyWhen` with the canonical
null predicate (maintainer ruling 2026-08-20, option A) |
| `objectstack-ai#10274` | 1/1 | 1/0 | `d1ba685ec`: re-measures the objectui pin
citations and gates the class |
| `objectstack-ai#10329` | 6/2 | 6/0 | `15d58dbf1`: retires the import lookup
transform's steering params (ADR-0049) |
| `objectstack-ai#10347` | 2/1 | 2/0 | `530c1df65`: the Archiver honours a declared
`ttl` (maintainer ruling 2026-08-20) |
| `objectstack-ai#10527` | 2/1 | 1/1 | `5649efbf9`: refuses a diverging retention +
ttl + archive triple at parse time |
| `objectstack-ai#11065` | 7/3 | 5/2 | `20950404c`: a boolean aggregand counts as 1 or
0 in `avg` and `sum`, the first face aligned. No commit message names
the card; this is where the number first entered the tree |
| `objectstack-ai#11195` | 3/1 | 2/1 | `b37231883`: `UserActionsConfigSchema` adopts
`group` / `hideFields` / `rowColor` |
| `objectstack-ai#11215` | 1/1 | 1/0 | `42a117b88`: documents
`NoSQLIndexSchema.unique`'s deliberate scope-vocabulary omission |
| `objectstack-ai#11350` | 1/1 | 1/0 | `ece4dad31`: records the 2026-08-23 maintainer
ruling on entry nameability (stage 1's anchor too) |
| `objectstack-ai#11408` | 2/1 | 1/1 | `f11fc61c5`: declares `editMode` (maintainer
ruling 2026-08-24) |
| `objectstack-ai#11507` | 5/2 | 5/0 | `88b9d749a`: declares `sys_activity.type` an
open, author-extensible vocabulary (maintainer ruling 2026-08-24,
direction 4) |
| `objectstack-ai#11658` | 1/1 | 1/0 | `1a6a19c31`: opens `RecordActivityProps.types`
to author-contributed kinds |
| `objectstack-ai#12380` | 4/2 | 4/0 | `4045b954d`: makes the SQLite `Field.json`
codec injective; its message carries the measured boundary |
| `objectstack-ai#12868` | 1/1 | 0/1 | a test title only. Its comment site sits in
`object.form.ts`, now held by PR objectstack-ai#20519; its deciding commit is
`c459da6bc` (see Acceptance notes) |
| `objectstack-ai#13156` | 1/1 | 1/0 | `fd289be45`: strips tracker ids from
function-declaration-built refusal prose (the card's A half) |
| `objectstack-ai#13644` | 3/2 | 2/1 | `34ce8e7db`: declares
`ctx.referentialFieldClear` on `HookContextSchema` |
| `objectstack-ai#14426` | 2/2 | 1/1 | `40a44b91b`: the undefined-comparand refusal
prescribes the null predicate by its ruled spellings, position-safe |
| `objectstack-ai#14676` | 1/1 | 1/0 | `13c48c2a5`: retires `connector.errorMapping`;
its test states the same assertion-set reasoning |
| `objectstack-ai#16126` | 2/2 | 2/0 | `859ded3ec`: refuses a whitespace-only
`reference` on lookup / master_detail |
| `objectstack-ai#16685` | 4/2 | 4/0 | `ed7243d52`: accepts boolean / toggle for sum /
avg / min / max (decision batch objectstack-ai#80) |
| `objectstack-ai#16867` | 3/2 | 2/1 | `0ee32edef`: `notNull` / `not_null` prescribe
`storage.notNull`, not `required` |
| `objectstack-ai#17014` | 3/2 | 2/1 | `80aef8032`: the one-day date-range presets
prescribe a one-day window, and the table states its end-token
convention |
| `objectstack-ai#17286` | 1/1 | 1/0 | dropped: a tracking card with no landing. The
sentence now says the card is gone and to measure `driver-memory` for
the open set |
| `objectstack-ai#17348` | 1/1 | 1/0 | `51efbf116`: pins the `driver-memory` temporal
text-operator divergence by name in that driver's conformance suite |
| `objectstack-ai#17590` | 1/1 | 1/0 | `e04a0aff2`: `$contains` on a JSON column is a
per-dialect membership test (director-seat ruling 2026-09-12) |
| `objectstack-ai#18012` | 8/3 | 7/1 | `176b03582`: `$between` requires two non-blank
endpoints (decision batch objectstack-ai#146 item 5, letter A) |
| `objectstack-ai#19377` | 6/2 | 6/0 | `a60c913de`: refuses a `{ $field }` reference
as a `$between` endpoint at the runtime filter door |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1), and every one is an ancestor of the base
(`merge-base --is-ancestor`, exit 0). That is 38 distinct shas.

Wordings to check, each true of its commit:
- `datasource.zod.ts:352` names only the card's mongo arm (`90a12fb18`)
for "the defect class … closed", because the paragraph is about mongo.
The card's mysql arm (`72050cc47`) is not cited anywhere in this stage.
- `datasource.zod.ts:354`: 「the triage's, as commit d491625 landed
them」. `d491625c1`'s message lists the fences as "per triage".
- `filter.zod.ts:1021-1025`: the `objectstack-ai#17286` pointer becomes 「was measured
on a tracking card … That card is gone: measure `driver-memory` for the
open set, ⛔ not this text.」 The warning that this paragraph is not the
authority is kept.

## The 41 string sites left as tokens

- **Test titles and test-code strings (39 sites).**
`driver/driver-credential-refusal.test.ts` 14, `object.test.ts` 6,
`datasource-credential-redaction.test.ts` 3,
`driver/driver-placeholder-refusal.test.ts` 3, `filter.test.ts` 3,
`api-derivation.test.ts` 2 (the `split('[objectstack-ai#6259]')` literal and its
message), `field.test.ts` 2, and 1 each in `date-range-presets.test.ts`,
`driver/postgres.test.ts`, `field-rows-option-description.test.ts`,
`filter-comparand-type.test.ts`, `hook.test.ts` and
`object-strictness-batch20.test.ts`.
- **Non-test strings (2 sites).** `aggregation-conformance.ts:398` and
`:407`, the `note` of two exported `AGGREGATION_CASES` rows (`objectstack-ai#11065`,
`objectstack-ai#11151`). They ship as data. Their only readers are driver conformance
suites, which print a `note` as the assertion message when a case fails,
to a driver developer and never to a metadata author. So they are
neither comments nor form D author-shown text. This is the same
disposition stage 1 gave the two `why` strings and stage 2 the
`PROVENANCE_WAIVERS` reason.

No author-shown text in `data/` carries a dead number, so nothing here
is objectstack-ai#20233's form D.

## Mechanical guard: no code token moves

The check compares leaf tokens with comments stripped, base `9bf5e67af`
against head `96fd49caa2`. It uses the TypeScript parser's leaf tokens,
so template literals are scanned in context, and it excludes JSDoc
nodes. It ran over all 45 touched `.ts` files.

- Real run: 140,379 base tokens, **0 files with a token change** (exit
0).
- Comment-insertion control: 0 files changed, as expected (exit 0).
- Positive control (a declaration inserted into `feed.zod.ts`): 1 file
reads DIFFER (exit 1).
- Positive control (one digit changed inside the `split('[objectstack-ai#6259]')`
string in `api-derivation.test.ts`): 1 file reads DIFFER (exit 1).

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/spec`
is included. It says only that the provenance comments were re-anchored.

Measured on the built package: 14 of the touched sources are
`src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten
docblocks also reach `dist`. `88b9d749a`, `e2798fab7` and `24206416a`
each appear in 1 declaration file. `24206416a` appears in 20 bundled
`.js` files and `2f3e79351` in 28. The positive control, a pre-existing
`feed.zod.ts` docblock sentence, appears in `dist/data/index.d.ts`.

## Gates (head `96fd49caa2`)

- **Citation judging pass, run as CI runs it:** `pnpm
check:issue-citations && node scripts/check-issue-citations.mjs` exits
0. The self-test passes 73 cases in 7 batteries. The live run judged 11
citations across 25 files, and all 11 resolve.
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at the final head derived 108
families, and all 108 exit 0. `--ran` reports 108 run, 0 NOT MEASURED, 0
unrun, and exits 0. (`check:i18n` was derived at the earlier heads from
`object.form.ts`, and left the set when that file went back to base.)
- At an earlier head, four gates first exited 3 (PREREQUISITE NOT MET)
because the workspace was unbuilt: `check:doc-formula-expressions`,
`check:doc-security-posture`, `check:skill-examples` and
`check:docs-transcript-drift`. At the final head a full `turbo run
build` of `./packages/*` ran first (71 tasks, exit 0, under the shared
verify lock), and every gate exited 0 on its first run.
- `check:generated` was run under the lock against that build: all 15
artifacts are up to date.
- **Build, tests, typecheck and lint:**
  - `pnpm --filter @objectstack/spec build` exits 0.
- `vitest run --maxWorkers=2 src/data` in `packages/spec` at the final
head: 107 files and 3,517 tests pass (1 todo), covering every touched
test file.
- The 12 spec suites outside `src/data` that read `data/` source text
pass at the final head: 12 files, 503 tests. These are
`scripts/{file-description,root-index,skill-map-guards,strictness-ledger}.test.ts`,
`src/api/api-entry-graph.pin.test.ts`,
`src/contracts/scoped-context.test.ts`,
`src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence}.test.ts`,
`src/system/constants/platform-object-names.test.ts`,
`src/type-alias-convention.pin.test.ts` and `src/ui/dashboard.test.ts`.
- `pnpm --filter @objectstack/spec typecheck` at the final head exits 0,
including `check:test-typecheck` (53 files, 251 errors, 138 pinned
signatures held).
- Lint, as a proven narrowing at the final head: `eslint
--no-inline-config --format json` over the 45 touched `.ts` files gives
45 files, 0 errors and 0 warnings. All 45 are in eslint's own population
(`isPathIgnored` is false for each). `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`, which its own line 328
states), so a comment edit here cannot move the verdict on any untouched
file. The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **The `[objectstack-ai#6259]` marker.** `api-derivation.test.ts:236` splits
`DATA_ACTION_TO_API_OPERATION`'s TSDoc on the literal `[objectstack-ai#6259]`, and a
test string may not change here. So the marker line
`api-derivation.ts:163` is byte-identical to the base, and the test
comment at `:232` that names the marker stays too. The sentence's
deciding commit sits on the next line instead: 「(both by commit
6968885)」. A first attempt wrote the commit onto the marker line
itself. The diff-scoped `check-issue-citations` then read the kept
`objectstack-ai#6259` as an added citation and exited 1, so it was moved one line down
(commit `b93f08f8d0`).
- **objectui's `objectstack-ai#6111`.** `field.zod.ts:370` reads 「objectui#6110 +
objectstack-ai#6111 (section)」. The qualifier covers only the first number, so the
citation grammar reads `objectstack-ai#6111` as this repository's (404 here). It is
objectui's number: its introducing commit `f887e5249` writes
`(objectui#6111)` in the same diff, and `objectstack-ai/objectui`
answers REST 200 for objectstack-ai#6111 to this session (and for objectstack-ai#6110 and objectstack-ai#10264).
objectui has no `refs/pull/6111/head`, so it is an issue there, not a
PR. The line is left unchanged. This is objectstack-ai#20330's grammar family, the
same as stage 2's `objectui PR objectstack-ai#10264`, and it is noted there, not
filed.
- **Capitalised qualifiers.** `CITATION_RE` classes `Pre-#N`, `POST-#N`
and `Framework#N` (7 sites in `data/`) as cross-repo and never judges
them. This census read them as this repository's. One was dead and is
rewritten here (`object.test.ts:223`, `POST-objectstack-ai#10347`). This is the same
objectstack-ai#20330 family as stage 1's `pre-` / `post-` finding.
- **Four files held after the claim.** Each joined the exclusions and
went back to the base bytes (hypothesis 2 of the dispatch). Each PR's
hunks were disjoint from this PR's lines, but the dispatch's rule is
file-level.
- `driver/turso.test.ts`: PR objectstack-ai#20504 (objectstack-ai#20437's) opened at
2026-09-28T20:08Z and edits it. Its two comment sites (`:4`, `:58`, both
`objectstack-ai#6345`) went back to blob `7fe99ebf9` in commit `86463ed0a1`. A
no-driver `merge-tree` of that head with PR objectstack-ai#20504's head `5dfa45e9f`
exits 0.
- `object.form.ts`: PR objectstack-ai#20519 (objectstack-ai#20432's) opened at 21:55Z and edits it.
Its one comment site (`:256`, `objectstack-ai#12868`, whose deciding commit is
`c459da6bc`) went back to blob `60713e06f` in commit `3479600dda`.
- `object.zod.ts`: PR objectstack-ai#20521 (objectstack-ai#20494's) opened at 22:10Z and edits one
line at `:2123`. Its 15 comment sites (`objectstack-ai#8772`, `objectstack-ai#10165`, `objectstack-ai#10347`,
`objectstack-ai#10527`, `objectstack-ai#11195`, `objectstack-ai#11408`, `objectstack-ai#13608`) went back to blob `befde04ca` in
commit `96fd49caa2`. Their deciding commits are `75b7c240a`,
`801296050`, `530c1df65`, `5649efbf9`, `b37231883`, `f11fc61c5` and
`fc9ba76a5`, all read for this stage.
- `filter-logic-conformance.ts`: PR objectstack-ai#20523 (objectstack-ai#20444's) opened at 22:39Z.
Its 3 comment sites (`objectstack-ai#13195`) went back to blob `c9b32acba` in the same
commit. Their deciding commit is `9dac1ae01`, with `PR objectstack-ai#13529` as the
link.
- **What stays for later stages.**
- The 33 dead sites in the held files listed above. The later stage can
reuse the deciding commits named for them here.
  - The 41 string sites and the 3 deliberate comment sites above.
- The `data/` numbers that also appear in
`packages/spec/src/migrations/**`. Those are objectstack-ai#20233's form D, or the
migrations stage.
- **The rung.** Several anchored changes also have ADR-0087 entries in
`packages/spec/src/migrations`. Examples are
`cbp-master-detail-required-forced` for objectstack-ai#8772,
`filter-between-blank-endpoint-refused` for objectstack-ai#18012, the `datasource-*`
entries for objectstack-ai#9040, objectstack-ai#9041 and objectstack-ai#8873, and the
`mapping-lookup-params-removed` conversion for objectstack-ai#10329. This PR takes the
commit rung, as stages 1 and 2 did, so it is precedent-consistent. The
D3 id is the more durable in-repo record, if the ruling's first rung is
later read to include those entries.
- **The citation gate's reach.** It defers `packages/**/*.test.ts`, so
20 of the 45 touched `.ts` files never enter its judging population. The
added-minus-removed count over the whole diff covers them: 0 numbers
added.
- **Base.** The branch is 22 commits behind `origin/main` (`1378ec7c0c`,
read at 2026-09-29T00:18Z). Four of those commits touch `data/`, all in
excluded files: objectstack-ai#20475's `hook.form.ts`, objectstack-ai#20487's `data-engine.*`, and,
since this stage excluded them, PR objectstack-ai#20521's `object.zod.ts`
(`9e1689f8e2`) and objectstack-ai#20444's `filter-logic-conformance.ts`
(`fb386074f5`). None touches a file in this diff, and a no-driver
`merge-tree` of the head onto `1378ec7c0c` exits 0. So there was no
merge. The open-PR file lists were re-read at 00:18Z: 11 open PRs, none
touching a file in this diff.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 30, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…/ searchFields (objectstack-ai#20776)

Fixes objectstack-ai#20500

Clause-②: no

## What changed

Two files, three places, all inside the claim surface (claim 5903909164
with amendments 5904148643 and 5905295272). In
`skills/objectstack-query/SKILL.md`, the Calling Convention section: (1)
The engine `aggregate` row (`:26`): the legal option keys now list
`search` and `searchFields` after `timezone`, with one clause saying the
two search keys filter the input rows **before** grouping, AND-ed with
`where`, exactly as on `find`. (2) The passthrough paragraph (`:38-44`):
it said all six driver passthrough keys are deliberately ILLEGAL on
`count` and `aggregate`; `timezone` is one of the six and is a legal
option on `aggregate` in its own right (in
`ENGINE_AGGREGATE_OPTION_KEYS`, read by `aggregate()` for date
bucketing), which the row above already showed. The paragraph now carves
that one key out: `aggregate` reads it itself, so it is legal there and
the row lists it; `count` refuses it with the rest. (3) In
`skills/objectstack-query/rules/aggregation.md:49` (round 3,
`cc924448`): "`fields` is not one of the six keys `engine.aggregate()`
accepts" no longer states a count — "not one of the keys
`engine.aggregate()` accepts" — so it cannot contradict the row it
cites, which now lists eight. Net +2 lines across the three commits
(`aggregation.md` net 0). Nothing else moved: the closed-set sentence at
`SKILL.md:31` and the `fields` / `orderBy` sentence (now `:254`) stay,
both still true.

Landing sites: the row the dispatch expected (`:26`); the `:38-44`
paragraph the seat added after the first report's out-of-scope finding;
and `rules/aggregation.md:49`, which the at-tier contract review of
`a87b7380` (5904504985, FAIL) found — a sentence that names a count,
invisible to the first census's key-name probes. The `content/docs/**`
half of the census (two docs sentences that enumerate the same set) is
carded as objectstack-ai#20792, ⛔ not in this PR.

## In-place fix (patch round, `a87b7380`)

In-place fix under the bounded exemption (claim amendment 5904148643),
all four conditions holding: ① the same defect class as the card — the
skill misstated the legal `aggregate` option set (the `:26` row
understated it; the `:38-42` paragraph overstated the illegal set by one
key); ② mechanical, in an already-pinned form — the carve-out names
`timezone` as the one passthrough key `aggregate` accepts and reads; ③
no other claim holds the file (no open PR touches
`skills/objectstack-query/**`, per the claim's serial-constraints
reading); ④ the same gate family — the list derived at `a87b7380` is the
same 24 commands as at `f997cf8e`, no new verification surface. Measured
on `origin/main` `c9c182ed` before writing: `ENGINE_COUNT_OPTION_KEYS`
(`packages/objectql/src/engine.ts:556`) is `context`, `where`, and
`count()` rejects against it (`:16168`), so `timezone` is refused on
`count`; `ENGINE_AGGREGATE_OPTION_KEYS` (`:562-565`) contains `timezone`
and none of `transaction`, `tenantId`, `tenantIds`, `bypassTenantAudit`,
`preserveAudit` (0 hits each); `aggregate()` spans `:16267-16686` and
reads `const tz = query.timezone` at `:16598`. The replacement sentence
reuses the file's own vocabulary ("date bucketing", `:89` and `:257`;
"the row above").

## Why

Since PR objectstack-ai#20487, `ENGINE_AGGREGATE_OPTION_KEYS`
(`packages/objectql/src/engine.ts:562` at `c9c182ed`) is `context`,
`where`, `groupBy`, `aggregations`, `having`, `timezone`, `search`,
`searchFields`. The row stopped at `timezone`, and together with the
closed-set sentence it told an agent that a searched aggregate is
refused. An agent reading that would group a searched page on the
client, which is the wrong-number workaround objectstack-ai#20358 retired: group
counts over a page window instead of over all searched rows, with no
error.

## Behaviour verified at the code, not at the comment

`aggregate()` (`engine.ts:16267-16277`):
`rejectUnknownEngineOptions(object, 'aggregate', query,
ENGINE_AGGREGATE_OPTION_KEYS)` admits both keys, then
`expandSearchOnAggregateOptions` (`:11104-11114`) builds a carrier AST
from `where` + `search` + `searchFields` and runs the one ADR-0061
expander `find` uses, `expandSearchOnAst` (`:11062-11086`): each term
becomes an `$or` of `$icontains` over the resolved searchable fields,
the result is AND-ed with the caller's `where` (`{ $and: [where,
searchFilter] }`), and the two search keys are deleted from the bag. All
of that runs before the aggregate AST is built and before any grouping,
so the grouped answer is the grouping of the searched rows, which is
what the new clause says.

## Census (list-shaped probes, as the dispatch asked)

At `c9c182ed`, over `skills/` and `content/docs/`:

- lines naming both `having` and `timezone`: 1 hit,
`skills/objectstack-query/SKILL.md:26` (the row corrected here);
- lines naming `groupBy`, `aggregations` and `having` together: 2 hits,
the same row and
`content/docs/kernel/runtime-services/data-service.mdx:91`, which lists
the `QueryAST` clauses (it already names `search`; it is not an
enumeration of the engine option set, so no edit);
- control, `ENGINE_AGGREGATE_OPTION_KEYS`: 2 hits, `SKILL.md:31` and
`:254` (`:252` before this PR), both prose sentences, both still true;
- control, the engine `aggregate` row spelling: 1 hit, `:26`.

`skills/objectstack-query/rules/aggregation.md:104` already says `where`
filters the input rows before grouping, the vocabulary the new clause
reuses.

**Round-3 census (count-shaped probes, after the review).** Over all six
files of `skills/objectstack-query/**`: number words, `only` near
`accept` / `key` / `option`, `keys` near `accepts` / `legal` / `closed
set`, and list-shaped rows, with the literal `engine.aggregate()` as
control — the one stale statement was `rules/aggregation.md:49` ("six
keys"), fixed here; every other number word counts a set of that size.
Over `content/docs/**`: two sentences enumerate the aggregate set
falsely — `content/docs/protocol/objectql/query-syntax.mdx:1337` and
`content/docs/data-modeling/queries.mdx:672` — carded as objectstack-ai#20792 with the
evidence and a proposed patch; they are outside this PR. The full probe
list and hit counts are in the dev report 5904713155.

## The two `skills/**` readings

Tokens in the ratchet's own convention, `ceil(utf8 bytes / 4)`.

| Reading | Before (`c9c182ed`) | After (`cc924448`) | Delta |
|:--|:--|:--|:--|
| touched file `skills/objectstack-query/SKILL.md`, lines | 399 | 401 |
+2 |
| touched file, tokens (ceiling 5552) | 3915 | 3990 | +75, headroom 1562
|
| touched file `skills/objectstack-query/rules/aggregation.md`, lines |
241 | 241 | 0 |
| touched file, tokens (ceiling 2357) | 1846 | 1845 | −1, headroom 512 |
| whole package `skills/**` (every file), lines | 13424 | 13426 | +2 |
| whole package `skills/**` (every file), tokens | 155899 | 155973 | +74
|
| all ten `SKILL.md`, lines | 4404 | 4406 | +2 |

PM line budget for this card: net +2 lines at most; +2 used (0 by the
row commit `f997cf8e`, +2 by the paragraph commit `a87b7380`, 0 by
`cc924448`), not exceeded. No existing line was re-wrapped:
`SKILL.md:38-41` are byte-identical to `main`, `:42` was extended in
place and two lines follow it; `rules/aggregation.md:49` is replaced in
place.

## Changeset

`skip-changeset`, by measurement: no released package's `files[]` names
a `skills` path (every `packages/**/package.json` scanned; positive
control: `create-objectstack` lists `dist`, `README.md`,
`CHANGELOG.md`). Scaffolded projects pull the catalog from the
repository path `objectstack-ai/objectstack/skills` through the `skills`
CLI (`packages/create-objectstack/src/skills-install.ts:62`), never from
an npm tarball.

## Gates

**Head `cc924448` (round 3):** the same 24 derived families plus
`check:skill-refs` ran green on `cc924448` (dev report 5904713155);
their `--ran` reconciliation is carried by the report's 47-family
superset over the round's working tree, not by a 24-only run. CI on
`cc924448`: 31 check-runs — 23 `success`, 7 path-filtered `skipped`, 1
`failure` (`Check Changeset`, the missing `skip-changeset` label; see
*Changeset*); `Lint & Repo Gates`, `TypeScript Type Check` and `Type
Check · source gates` (the token ratchet and the skill-doc gates)
`success`. The table below is the round-2 record on `a87b7380`.

Derived from the actual change with `node scripts/pm/dispatch-gates.mjs
--repo objectstack-ai/objectstack --commands` at head `a87b7380` (24
commands, the same list as at `f997cf8e` and as the dispatch), all run
on head `a87b7380`, exit codes captured before any pipe, reconciled with
`--ran`.

| Gate (as `--commands` printed it) | Exit at `a87b7380` |
|:--|:--|
| `node scripts/check-ci-filter-parity.mjs` | 0 |
| `node scripts/check-closing-keyword-parity.mjs` | 0 |
| `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 |
| `node scripts/check-comment-mask-corpus.mjs` | 0 |
| `node scripts/check-doc-route-spelling.mjs --advisory` | 0 |
| `node scripts/check-doc-route-spelling.mjs --self-test` | 0 |
| `node scripts/check-skills-token-ratchet.mjs` | 0 |
| `node scripts/check-skills-token-ratchet.mjs --self-test` | 0 |
| `pnpm --filter @objectstack/lint run check:doc-formula-expressions` |
0 |
| `pnpm --filter @objectstack/spec run check:skill-docs` | 0 |
| `pnpm check:agent-test-spelling` | 0 |
| `pnpm check:corpus-claim-drift` | 0 |
| `pnpm check:cross-package-test-inputs` | 0 |
| `pnpm check:doc-authoring` | 0 |
| `pnpm check:driver-memory-census` | 0 |
| `pnpm check:gitlink-declared` | 0 |
| `pnpm check:nul-bytes` | 0 |
| `pnpm check:pm-governed-merges` | 0 |
| `pnpm check:refd-timer-probe` | 0 |
| `pnpm check:role-word` | 0 |
| `pnpm check:skill-compatibility` | 0 |
| `pnpm check:skill-frame-sync` | 0 |
| `pnpm check:skill-identifier-liveness` | 0 |
| `pnpm check:watch-hint-literal` | 0 |
| `pnpm --filter @objectstack/spec run check:skill-refs` (extra, not
derived; AGENTS.md names it for a `SKILL.md` edit) | 0 |

`--ran` reconciliation at `a87b7380`: 24 derived, 24 run, 0
NOT-MEASURED, 0 UNRUN (`dispatch-gates --ran`, exit 0). Ratchet family:
`check-skills-token-ratchet` prints `skills/objectstack-query/SKILL.md
is 3990 tokens (ceiling 5552; headroom 1562)`.
`check:doc-formula-expressions` needs `@objectstack/formula` and
`@objectstack/lint` built; they were built under `os-verify-lock.sh`
before the gate ran (turbo cache hit, VERDICT command-exit 0), so this
round it passed on its first run. At the earlier head `f997cf8e` the
same 24 were green too (that round's first
`check:doc-formula-expressions` run exited 3 for the missing build, NOT
MEASURED, and passed after the build). `check:skill-docs` reads
frontmatter only (`packages/spec/scripts/build-skill-docs.ts`), so no
generated artifact moves for a body edit; `skills/README.md` and
`content/docs/ai/skills-reference.mdx` stay byte-identical. No package
build or test is owed: the diff touches no package (`turbo ls
--affected` is blind here by construction — the skill lives outside the
package graph).

A repo-wide `pnpm lint` (`eslint . --no-inline-config`) is CI-owned; it
was not run locally and is not claimed (NOT MEASURED locally, CI reports
it). Population reading from eslint's own configuration: `pnpm exec
eslint --print-config skills/objectstack-query/SKILL.md` prints
`undefined` (no config block matches the file), and every `files:` glob
in `eslint.config.mjs` is a `{ts,tsx,mts,cts,js,jsx,mjs,cjs}` pattern,
so the one edited file is outside eslint's population and this diff
cannot move any lint verdict on any file.

## Acceptance notes

- The first round's one out-of-scope observation — `SKILL.md:38-42` said
all six driver passthrough keys are illegal on `aggregate`, while
`timezone` is legal and read there — is fixed in this PR by the patch
round `a87b7380`, under the bounded in-place exemption (see *In-place
fix* above).
- The `content/docs/**` half — `query-syntax.mdx:1337` and
`queries.mdx:672` enumerate the aggregate option set as "only `where` /
`groupBy` / `aggregations`" — is carded as **objectstack-ai#20792** (filed for triage;
`domain:devx` by the lane table), carrying the evidence and the round-3
dev's proposed patch. `Fixes objectstack-ai#20500` closes the card over the
`skills/**` half, which is the card's own defect; the docs half lives on
objectstack-ai#20792.
- Noted, not carded: the doc comment on `ENGINE_DRIVER_PASSTHROUGH_KEYS`
(`packages/objectql/src/engine.ts:507-513`) still says the six keys are
"deliberately NOT legal" on `count` / `aggregate` — stale by `timezone`
on `aggregate`; a code comment, not a shipped surface (carrier: the next
edit of that block).

## 维护者速读(草稿)

- **改了什么**:`skills/objectstack-query` 技能里「Calling Convention」表的 engine
`aggregate` 一行,合法选项键补上 `search`、`searchFields`,并加一句:这两个键在分组之前过滤输入行,与
`where` 取交集,行为与 `find` 一致。另将同段落里「六个直通键在 count / aggregate 上一律非法」的表述修正为
timezone 例外(aggregate 自己读取它做日期分桶,count 仍拒绝);`rules/aggregation.md` 里「six
keys」这一数字去掉(表里已是八个键)。技能包净 +2 行。`content/docs` 里同样写错的两句另立 objectstack-ai#20792,不在本 PR。
- **为什么改**:PR objectstack-ai#20487 落地后,运行时的 `aggregate` 已经接受并执行 `search` /
`searchFields`,技能却还写着不接受。AI
读了这份技能会绕路:先查一页再在客户端分组,得到的分组计数只覆盖一页而不是全部命中行,且没有任何报错。写给 AI 的技能说错一句等于产品缺陷。
- **风险与代价(含回滚)**:纯文档改动,不动代码、不动发布包(`skills/**` 不在任何 npm 包的 `files[]`
里,脚手架项目从仓库路径拉取)。token 棘轮上限未动(SKILL.md 3915 → 3990 / 5552;aggregation.md
1846 → 1845 / 2357)。回滚即 revert 本 PR。
- **席位意见**:
- **你要做的**:确认这一行的措辞,批准后由席位落地(受管面 Tier H,草稿 PR)。

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

---------

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(metadata-protocol): a query carrying groupBy / aggregations silently drops search — grouped counts under a search are the unsearched counts

2 participants