Skip to content

fix(spec,service-automation)!: QuerySchema.fields and the get_record node's fields refuse an empty projection - #27244

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-bxp8pd
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-bxp8pd

Conversation

@os-support-ai

@os-support-ai os-support-ai commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Yibo Wang · project thread

Fixes #27166

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — skills/** teaches fields and nested expand fields only as non-empty lists (skills/objectstack-query/SKILL.md: the nested expand value row and the nested-expand example), and teaches the get_record node without fields (skills/objectstack-automation/SKILL.md); no file teaches an empty projection, and every taught use stays true.

维护者速读

  • 改了什么:最后两份还接受空投影的公开声明——QueryAST 自身的 QuerySchema.fields(因而也是每一层 expand 条目的值类型)和流程 get_record 节点的 fields——改为与其余投影声明共用同一条规则:至少一个字段名,且不能为空白。报错文字同一处定义,不复制。
  • 为什么改:fix(objectql)!: the in-process engine refuses an empty projection, as every published declaration does #27161 之后引擎在 find / findOne 入口(顶层与任意层 expand)拒绝空投影,而这两份声明仍写着「空也收」:get_record 节点写 fields: [] 能保存,运行时才失败。现在保存时就被拒。
  • 风险与回滚:低。仓内没有任何非测试的作者写这两种空投影;不写 fields 仍读全部字段,非空列表不变,类型不变(string[])。附带一处可见变化:POST /data/:object/query 请求体里 fields: [] 由请求体 schema 拒绝(400 VALIDATION_FAILED,query.fields),与其别名 $select: [] 的回答一致;此前由 normalizer 拒绝(400 VALIDATION_ERROR)。两者都是拒绝,只是错误码变了。回滚即 revert。
  • 先看:packages/spec/src/data/projection-names-a-field.ts(共享规则移入的新模块)、query.zod.ts 与 builtin-node-config.zod.ts 各一行、packages/spec/src/data/projection-names-a-field.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/data/projection-names-a-field.test.ts,预期 28/28 通过;把 query.zod.ts 的 fields 改回 z.array(FieldNodeSchema) 时 15 条失败。
  • 需要你做的:无,席位落地。

What changes

Shape main (9dacccf) This head
QuerySchema.parse({ object, fields: [] }) parses refused at fields: "Name at least one field"
QuerySchema with expand: { rel: { object, fields: [] } }, any depth parses refused at expand.rel.fields (or deeper), same words
EngineQueryOptionsSchema with an expand entry's fields: [] parses refused at expand.rel.fields, same words
fields: ['name', ''] / ['name', ' '] on any of the above parses refused at fields.1: "A field name cannot be blank"
QueryWithTransportSchema canonical fields: [] parses (its $select: [] alias was already refused) refused at fields, the same message $select: [] gets
POST /data/:object/query body { "fields": [] } 400 VALIDATION_ERROR from the normalizer (on 18.2.0: 200, every column) 400 VALIDATION_FAILED at query.fields from the body schema, as $select / select
POST /data/:object/query body { "expand": { "owner": { "object": "sys_user", "fields": [] } } } 400 VALIDATION_ERROR from the engine door (on 18.2.0: 200, every column of the related record) 400 VALIDATION_FAILED at query.expand.owner.fields from the body schema
GetRecordConfigSchema fields: [] parses refused at fields, same words
a flow get_record node with fields: [] saves; run fails with the engine's refusal defineStack refuses it with 422 STACK_SCHEMA_INVALID at flows.0.nodes.1.config.fields; the save door's node judge (flowNodeConfigRefusals) refuses it at fields; the executor refuses the config before any read
fields absent, or a non-empty list parses unchanged
  • One declaration, not a copy. PROJECTION_NAMES_NO_FIELD and transportProjectionArray move from data-engine.zod.ts into the new packages/spec/src/data/projection-names-a-field.ts. They had to move: data-engine.zod.ts imports query.zod.ts, so query.zod.ts could not read them where they were. The module stays outside the data barrel, so no public export is added (as with filter-save-door-refusals.ts). data-engine.zod.ts imports both names and keeps its string arm and its docblock; the docblock gains one paragraph that says where the array arm now lives and why.
  • BaseQuerySchema.fields becomes transportProjectionArray(FieldNodeSchema), the construction EngineQueryOptionsSchema.fields and QueryTransportParamsSchema.$select already use. QuerySchema is one declaration, so this also covers its top level (the driver tuple inputs, the API contract input, the canonical fields of QueryWithTransportSchema) and every nested expand value. Seat 1 ruled for narrowing the base declaration rather than an expand-only copy of the recursive schema.
  • GetRecordConfigSchema.fields becomes transportProjectionArray(z.string()).
  • Both .describe() texts now say that an empty list or a blank name is refused and that omitting fields reads every field. The five reference pages that render them are regenerated (gen:docs).
  • @objectstack/service-automation: the get_record designer form declares fields with minItems: 1, the way its limit mirrors the contract's floor, so the form no longer offers an empty list the save refuses. builtin-node-form-zod-ledger.test.ts pins both sides at that boundary, beside the limit pin.
  • ADR-0087: registered query-and-get-record-empty-projection-refused, a new step-18 D3 semantic entry. There is no D2 conversion: the only mechanical rewrite of fields: [] is to drop the key, which reads every column, and that is the widening the rule refuses. Stored flows are not rewritten.
  • Changeset .changeset/27166-query-get-record-empty-projection.md: @objectstack/spec minor and @objectstack/service-automation minor (Metadata-protocol: narrowed takes minor on each package whose src/** the diff moves), with both declaration lines, the Skills-docs: line, the ADR-0087 marker and a BREAKING banner with Before / Now / Unchanged and the remedy.
  • content/docs/data-modeling/queries.mdx: the empty-projection sentence names each refusal point (4dbe7883). A POST body is refused by the schema (VALIDATION_FAILED at query.fields); the querystring forms (?fields=, $select=) keep VALIDATION_ERROR; engine.find in-process refuses it; a get_record node is refused when the flow is saved.
  • The changeset is anchored to @objectstack/spec@18.2.0. Its Before now describes the last release: there, a get_record node with fields: [] saved, registered and ran, reading every column, and a REST POST with fields: [] answered 200. data list door: a blank $select answers every column and an unrecognised $count value counts — malformed values are read as the absent default #26644 and objectql: the in-process engine reads fields: [] as every column while every published projection declaration refuses an empty projection (found on #26954) #27008 are in the same unreleased batch. The changeset also names the stored-flow consequence: such a flow is refused by registerFlow on every pull, and it stays unregistered with its triggers unbound until the row is edited. os migrate meta --stored has no conversion to apply to it. It also carries a Supersedes, in the same release: paragraph for the 26954-engine-projection-empty.md sentence that says engine.find(object, { fields: [] }) still reads every column, which objectql: the in-process engine reads fields: [] as every column while every published projection declaration refuses an empty projection (found on #26954) #27008 made false; the precedent is 25923-calendar-gantt-flat-fallback-retired.md. The D3 entry's reason and acceptanceCriteria say the same.
  • data-empty-projection-count-door.test.ts gains the expand row: the test checks for 400 VALIDATION_FAILED and fields[0].field = query.expand.owner.fields, and that the object was never read.

Why

#26954 (PR #27007) made the engine-side projection declarations refuse []. #27008 (PR #27161) made the in-process engine refuse it at the find / findOne entry, top level and on every expand entry. Its acceptance notes reported two declarations still admitting it: the QueryAST's fields, which is the value type of EngineQueryOptionsSchema.expand, and GetRecordConfigSchema.fields, which the get_record executor hands to data.find / findOne as written. So the runtime refused shapes these declarations admitted, and a get_record node with fields: [] was caught at run instead of at save. Triage 6466002857 set the lane to domain:spec.

Readers and producers (measured on origin/main 9dacccf)

  • Who parses these shapes at run time.
    • The get_record executor parses its config with GetRecordConfigSchema (crud-nodes.ts, parseNodeConfig).
    • defineStack / os validate and registerFlow judge every builtin node config through flowNodeConfigRefusals.
    • The REST body schema (FindDataRequestSchema.query = QueryWithTransportSchema) extends QuerySchema's shape, so its canonical fields narrows too.
    • No code outside packages/spec parses QuerySchema itself, nor the driver tuple inputs.
  • Census of authored empty projections on the two shapes. I ran git grep over packages, ui, examples, apps and skills.
    • One hit: get-record-id-projection.integration.test.ts:167, the fix(objectql)!: the in-process engine refuses an empty projection, as every published declaration does #27161 test that pinned the run-time refusal. It now pins the config refusal: the engine is never asked, and the node fails with config.fields: Name at least one field.
    • Two other tests pinned the old acceptance and are rewritten. query.test.ts ("should handle empty arrays") drops fields: [] and points at the new file. data-empty-projection-count-door.test.ts moves its fields: [] row from the normalizer table to the body-schema table and adds a blank-member row.
    • No examples/** or non-test source authors either shape empty.
  • The designer UI. ui/ offers no fields input for get_record (flow-node-config.ts has objectName, filter, limit, outputVariable), so no ui/ change is needed.
  • Types. z.input / z.infer stay string[]. typecheck exits 0 for spec, service-automation and rest.
  • JSON Schema. fields gains minItems: 1 and a non-blank pattern on each item. check:generated reported only the reference pages stale, and they are regenerated.

Verification

All at head 939accc9 unless a line says otherwise.

Round 2, on the merged tree (a1b4b045, merge 295699d5), all exit 0: check:generated; spec src/data + src/automation + src/migrations (189 files, 6392 passed); spec typecheck; service-automation src/builtin (82 files, 1154 passed); the three rest files (120 passed); check-adr-0087-registration, check-changeset-no-major, check-empty-changeset.

New pins and ablation

packages/spec/src/data/projection-names-a-field.test.ts has 28 cases:

  • QuerySchema refuses [], ['name', ''] and ['name', ' '] at the top level, on an expand entry and on a second-level expand entry. Each refusal asserts the path and the message EngineQueryOptionsSchema.fields gives for the same value.
  • EngineQueryOptionsSchema.expand refuses the same values on an entry.
  • QueryWithTransportSchema's canonical fields refuses them with $select's message.
  • GetRecordConfigSchema refuses them. The save door's node judge refuses them at fields / fields[1]. defineStack refuses a flow whose get_record node carries fields: [] with 422 STACK_SCHEMA_INVALID at flows.0.nodes.1.config.fields.
  • Controls: one or two names parse unchanged, and absent fields parses unchanged, on every shape.

Ablation after commit, through scripts/ablation-replace.mjs (anchor hit 1 → 0; restored to the HEAD blob with git diff HEAD empty):

  • query.zod.ts fields reverted to z.array(FieldNodeSchema): 15 failed / 13 passed. These are the five QuerySchema / expand / transport groups.
  • builtin-node-config.zod.ts fields reverted to z.array(z.string()): 7 failed / 21 passed. These are the three contract refusals, the three node-judge refusals and the defineStack case.
Local runs
  • pnpm --filter @objectstack/spec build && check:generated: only content/docs/references/** was stale. gen:docs was run and the result committed.
  • @objectstack/spec full suite, at bd35a3fa under the lock: 823 files, 23598 passed, 1 failed (query.test.ts "should handle empty arrays", the old acceptance pin, rewritten in the next commit; that file is then 91/91). typecheck: exit 0.
  • @objectstack/service-automation build, suite and typecheck, at bd35a3fa: 216 files, 2756 passed, 1 failed (the rewritten get_record pin, whose expected path read fields: where the executor prints config.fields:; fixed in the next commit, and that file plus the form ledger are then 17/17). typecheck: exit 0.
  • @objectstack/rest suite: 338 files passed, 6835 tests passed, 344 skipped.
  • @objectstack/metadata-protocol suite: 277 files passed, 4 skipped; 21750 tests passed, 26 skipped.
  • Full dogfood, owed by the (narrowing) arm, after the @objectstack/dogfood^... closure build: pnpm --filter @objectstack/dogfood test under the lock, no shard, no path: Test Files 348 passed | 3 skipped (351); Tests 2804 passed | 20 skipped (2824); exit 0.
Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 109 commands, all run serially at this head. 107 exited 0 on the first pass, including check-adr-0087-registration --base origin/main (registered query-and-get-record-empty-projection-refused (new here)), check-changeset-no-major --base origin/main and spec check:generated (14/14 up to date). Two exited 3 because their prerequisite was not met: spec check:skill-examples and check:dual-build-cjs-loads. They read built output. After I built the seven packages whose dist they named, both exited 0. --ran reconciliation: "109 derived famil(ies) accounted for — 107 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)", and those two then ran green. check-plugin-teardown-shape --self-test ran after fetching its pinned fixture commit. Also eslint --no-inline-config on the 11 changed .ts files: exit 0. @objectstack/rest typecheck: exit 0.

Acceptance notes

  • The REST code change is deliberate and is the ruling's consequence. A POST /data/:object/query body with fields: [] was refused before and is refused now. The code moves from VALIDATION_ERROR (normalizer) to VALIDATION_FAILED (body schema), and the refusal now names query.fields, as it already did for select / $select. The querystring spellings (?fields=, ?$select=) are unchanged: they go through the normalizer as before.
  • The in-process engine already refused these shapes (fix(objectql)!: the in-process engine refuses an empty projection, as every published declaration does #27161). This diff changes no runtime source except the designer form's minItems.
  • main was merged (merge commit, no rebase) at 295699d5. Main had moved content/docs/references/api/protocol.mdx and packages/spec/src/api/protocol.zod.ts, in hunks that do not overlap this diff. protocol.mdx is a generated artifact, so it was regenerated on the merged tree (gen:schema && gen:docs) in its own commit, not text-merged. After the merge, the PR's delta against main is 19 files: the 18 above, plus packages/runtime/src/domains/data-empty-projection-count.test.ts (c483a00e). The runtime dispatcher's door moves the same way as REST. Its POST body { "fields": [] } row moves from the normalizer table to the file's schema table and expects VALIDATION_FAILED at query.fields, next to the blank-member and expand rows, mirroring the rest pin. That row had turned Test Core 3/8 red on 4dbe7883.
  • Stored flows are not rewritten. A stored flow whose get_record node carries fields: [] already failed at run on main. It now also meets the refusal where a flow is registered or saved. The D3 entry tells authors to sweep for it.

Human Minimum

Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (No formatter ran. eslint --no-inline-config over every changed .ts file exits 0; the repo-wide pnpm lint is CI's.)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/ (the reference pages are gen:docs output).
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

… an empty projection

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
…node's fields refuse an empty projection

The QueryAST's own fields (and so every nested expand entry) and the flow
get_record node's fields take the array arm every other published
projection declaration takes: at least one name, none blank. The arm and its
sentence move to a non-barrel module both sides of the query.zod import edge
can read. Adds the D3 semantic entry, the designer form's minItems, and the
changeset; regenerates the reference pages.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
@os-support-ai os-support-ai self-assigned this Oct 28, 2026
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-automation, @objectstack/spec, touching 9 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query))
  • content/docs/api/data-api.mdx (via project_id (literal, a string literal in BaseQuerySchema), /data/:object/query (route, a path literal in acceptanceCriteria; a path literal in reason; a path literal in surface))
  • content/docs/api/wire-format.mdx (via /data/:object/query (route, a path literal in acceptanceCriteria; a path literal in reason; a path literal in surface))
  • content/docs/data-modeling/queries.mdx (via project_id (literal, a string literal in BaseQuerySchema), /data/:object/query (route, a path literal in acceptanceCriteria; a path literal in reason; a path literal in surface))
  • content/docs/data-modeling/schema-design.mdx (via project_id (literal, a string literal in BaseQuerySchema))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query), /data/:object/query (route, a path literal in acceptanceCriteria; a path literal in reason; a path literal in surface))
  • content/docs/permissions/system-context.mdx (via registerCrudNodes (symbol, a top-level function))
  • content/docs/protocol/kernel/http-protocol.mdx (via project_id (literal, a string literal in BaseQuerySchema))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via project_id (literal, a string literal in BaseQuerySchema))
  • content/docs/protocol/objectql/query-syntax.mdx (via project_id (literal, a string literal in BaseQuerySchema), /data/:object/query (route, a path literal in acceptanceCriteria; a path literal in reason; a path literal in surface))
  • content/docs/protocol/objectql/schema.mdx (via project_id (literal, a string literal in BaseQuerySchema))
  • content/docs/protocol/objectql/types.mdx (via project_id (literal, a string literal in BaseQuerySchema))

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

  • content/docs/releases/v17/17-5.mdx (via /data/:object/query (route, a path literal in acceptanceCriteria; a path literal in reason; a path literal in surface))
  • content/docs/releases/v17/17-6.mdx (via /data/:object/query (route, a path literal in acceptanceCriteria; a path literal in reason; a path literal in surface))

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
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 56 of 208 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 — 148 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 9ff8154cf694c616de5d467b35af21e037de1509 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9ff8154cf694c616de5d467b35af21e037de1509

⚠️ 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 9ff8154cf694c616de5d467b35af21e037de1509 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 939accc94e44b7765ea07c4f8d887ac382500c29
Local-runs: none
Scope: packages/spec/src/data/query.zod.ts, packages/spec/src/data/data-engine.zod.ts, packages/spec/src/data/projection-names-a-field.ts, packages/spec/src/data/projection-names-a-field.test.ts, packages/spec/src/data/query.test.ts, packages/spec/src/automation/builtin-node-config.zod.ts, packages/spec/src/migrations/entries/semantic/18.query-and-get-record-empty-projection-refused.ts, packages/services/service-automation/src/builtin/crud-nodes.ts, packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts, packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts, packages/rest/src/data-empty-projection-count-door.test.ts + packages/objectql/src/empty-projection-door.ts, packages/spec/src/api/protocol.zod.ts (FindDataRequestSchema), packages/rest/src/crud-routes.ts (the POST query door), packages/spec/src/automation/flow-node-config-refusals.ts, packages/services/service-automation/src/engine.ts (registerFlow), packages/services/service-automation/src/plugin.ts (the stored-flow pulls), packages/runtime/src/domains/automation.ts (registerAndSaveFlow), packages/metadata-protocol/src/protocol-query-validation.ts (servedExpand), packages/spec/src/api/contract.zod.ts, packages/spec/src/data/driver.zod.ts, packages/client/src/query-builder.ts, ui/packages/data-objectstack/src/index.ts, ui/packages/plugin-list/src/ListView.tsx, ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts, skills/objectstack-query/SKILL.md, skills/objectstack-automation/SKILL.md, docs/adr/0087-metadata-protocol-upgrade-contract.md, .changeset/26644-, 26909-, 26910-, 26954-, 27008-* (all read on origin/main fbe344b3; the diff read against its base 9dec0545). The five content/docs/references/** pages and the root changeset are prose faces: read for ②, not reviewed.

① Derived judgments

Each accept-set or public-surface change the diff implies, judged against the live tree.

  1. QuerySchema.fields (BaseQuerySchema, query.zod.ts) refuses [] and a blank or whitespace-only member — right. On main it is z.array(FieldNodeSchema).optional(). The in-process engine already refuses fields: [] at find / findOne (empty-projection-door.ts, objectql: the in-process engine reads fields: [] as every column while every published projection declaration refuses an empty projection (found on #26954) #27008, landed as 9fa70b4), reading its sentence off EngineQueryOptionsSchema.fields, which took the same transportProjectionArray(FieldNodeSchema) arm in spec: RpcQueryOptionsSchema.select and EngineQueryOptionsSchema.fields still admit an empty projection — decide ADR-0049 enforce-or-remove, then align with the shared projection rule (found reviewing #26909) #26954. The declaration now admits what the runtime serves. The blank-member refusal is stricter than the engine door (which judges only length === 0), but it is the arm every published sibling already carries (EngineQueryOptionsSchema.fields, $select, select, GetDataRequestSchema.select), so the QueryAST joins one rule rather than inventing a second.
  2. Every nested expand value, at any depth, refuses the same — right. QuerySchema is BaseQuerySchema extended with expand, a lazy z.record(z.string(), QuerySchema), so one declaration covers the top level and the recursion; EngineQueryOptionsSchema.expand is the same z.record(z.string(), QuerySchema). The engine door walks expand at every depth with the same refusal. Pinned at depth one and two in the new test.
  3. QueryWithTransportSchema canonical fields, hence FindDataRequestSchema.query.fields, hence the REST POST /data/:object/query body — right, and it matches the $select answer. QueryWithTransportSchema is queryObjectFace() (a cast of QuerySchema itself) extended with QueryTransportParamsSchema's members, so fields is the very arm $select's array arm is (transportProjectionArray(FieldNodeSchema)), the object parse refuses before the fold transform runs, and crud-routes.ts answers 400 VALIDATION_FAILED with fields[0].field = query.fields, as it answers query.$select / query.select since spec: QueryTransportParamsSchema.select / $select and GetDataRequestSchema.select admit an empty projection that the findData and getData doors refuse (sibling of #26817) #26909. Before this head the same body was refused one layer down by the normalizer (400 VALIDATION_ERROR, pinned in the old REFUSED_POST table). Refused before, refused now; only the code and the locating key move, and that is exactly the move spec: QueryTransportParamsSchema.select / $select and GetDataRequestSchema.select admit an empty projection that the findData and getData doors refuse (sibling of #26817) #26909 made for select. The querystring spellings (?fields=, ?$select=) never reach this arm and keep the normalizer's answer. One consequence the PR body's REST row does not name: a POST body whose expand.rel.fields is [] likewise moves from the engine door's VALIDATION_ERROR (served through mapDataError) to the body schema's VALIDATION_FAILED at query.expand.rel.fields. Right, but should be written down (see ④).
  4. The API contract's list.input (contract.zod.ts) and the driver tuple inputs (driver.zod.ts find / findOne / count / aggregate / stream) narrow with it — right. Both embed QuerySchema. No runtime in the repository parses either (grep over packages/** outside spec finds no QuerySchema.parse and no .input.parse of these tuples; TursoDriverSpec parses a driver definition, not a query), so no served request changes; what narrows is the published declaration and the JSON Schema a generated client reads.
  5. GetRecordConfigSchema.fields refuses [] and a blank member — right. On main it is z.array(z.string()).optional() while the executor (crud-nodes.ts 498–550) hands cfg.fields to data.find / findOne as written, where the engine refuses it. The narrowing reaches every door that judges a builtin config through flowNodeConfigRefusals: FlowSchema.parse, defineFlow, defineStack (422 STACK_SCHEMA_INVALID at flows.N.nodes.M.config.fields, pinned), os validate / compile, the metadata save door, AutomationEngine.registerFlow (which parses first), and the executor's own parseNodeConfig at run. The old run-time pin is rewritten to the config refusal with the engine never asked; the engine door keeps its own pin in packages/objectql/src/engine-empty-projection-refusal.test.ts, so nothing is left unpinned at a lower layer.
  6. A stored sys_metadata flow carrying fields: [] is refused at registration — right, and it is the established seam. registerFlow throws the first refusal from registrationChecks; every pull in plugin.ts (boot pull 1387, cold-boot bind 2596, re-sync 2520, mutation follow 2743) catches, warns with describeThrownForLog, and (where the flow was not already held) records it through recordFlowRefusal (automation: a stored flow the engine refuses at cold boot is withdrawn with a warn, while the metadata read keeps serving it as status: 'active' — after the v18 upgrade, every refused flow reads as running #23316), so the flow is readable as refused rather than silently absent. ADR-0087's 2026-07-31 addendum names registerFlow as the flow's own rehydration seam and the many protocol-18 *-refused semantic entries on flow nodes use exactly this path. The consequence for an upgrading deployment is stronger than the prose says, though: on 18.2.0 (the released line; objectql: the in-process engine reads fields: [] as every column while every published projection declaration refuses an empty projection (found on #26954) #27008 and data list door: a blank $select answers every column and an unrecognised $count value counts — malformed values are read as the absent default #26644 are unreleased) such a flow registered and ran, reading every column; after this head it does not register at all, so none of its nodes run and its triggers stay unbound until the row is edited. See ③ and ④.
  7. The designer form's fields gains minItems: 1 (crud-nodes.ts) — right. It mirrors the contract the way limit already mirrors its floor, and the form ledger test pins both sides. ui/ declares no fields input for get_record (flow-node-config.ts 816: objectName, filter, limit, outputVariable), so no ui/ change is owed.
  8. PROJECTION_NAMES_NO_FIELD and transportProjectionArray move from data-engine.zod.ts to the non-barrel data/projection-names-a-field.ts — right, no public surface changes. The module imports only zod, so no cycle; query.zod.ts could not have read them from data-engine.zod.ts, which imports query.zod.ts. Nothing is added to packages/spec/src/data/index.ts (precedent: filter-save-door-refusals.ts). Type Check · source gates (the lane carrying check:generated, check:api-surface, check:authorable-surface, check:docs) is success on this head. The automation-to-data deep import has precedent (flow-value-slot-template.ts reads ../data/context-tokens.zod), and Lint · ESLint is success.
  9. JSON Schema: minItems: 1 and a non-blank pattern on each member; TS types unchanged (string[]). Right. json-schema.manifest/automation.json and authorable-surface/automation.json list schema and key names only, so no regeneration beyond the five .describe()-rendered reference pages, which the diff regenerates.
  10. Census — is any in-repo producer newly refused? None. Every non-test fields: [] literal on main is something other than a projection: a screen descriptor (screen-nodes.ts 502), a CLI orphan report row, analytics result shapes, UI column lists. ListView.tsx's fields: [] is a column projection handed to ObjectGrid, and data-objectstack puts fields on the wire only when non-empty (GET list and export), so it never meets either schema. QueryBuilder.select() with no arguments does yield fields: [], but no in-repo caller invokes it bare, and that query was already refused at the REST door on main (data list door: a blank $select answers every column and an unrecognised $count value counts — malformed values are read as the absent default #26644); client.data.find({ select: [] }) is refused client-side since client: data.find sends ?select= for select: [] — refuse an empty projection at the call site, never drop the parameter (follow-up of #26817) #26910. Every in-repo expand producer authors entries without fields (setRecordExpander writes {} per relation; data-action-routes.ts writes { object: rel }; servedExpand only withholds nested expand; objectql's nested read appends id to a non-empty list). examples/** carries one get_record with a non-empty projection. The dev's census (one authored fields: [], in the fix(objectql)!: the in-process engine refuses an empty projection, as every published declaration does #27161 test) holds.

② Semver level

  • @objectstack/spec: minor — right. Metadata-protocol: narrowed takes at least minor on a package whose src/** the diff moves, and the diff moves query.zod.ts, builtin-node-config.zod.ts, data-engine.zod.ts and adds the module and the D3 entry.
  • @objectstack/service-automation: minor — right by the same rule: crud-nodes.ts moves (the form's minItems), and the level follows the kind alone, not the size of the hunk.
  • @objectstack/rest: no bump — right; only a test file moves, nothing publishes. @objectstack/objectql, @objectstack/metadata-protocol: untouched.
  • Clause-②: no (narrowing) on the PR body and in the changeset, with the BREAKING banner, Before / Now / Unchanged and the remedy — right: two published declarations stop admitting a list naming no field; nothing is widened, no key moves. The arm is BREAKING, so the full dogfood is owed; the dev ran it green and Dogfood Regression Gate is success on this head.
  • ADR-0087 disposition: exactly one marker, registered query-and-get-record-empty-projection-refused, naming an id that is new in the diff and resolves (semantic/18.*, discovered by build-migration-registry.ts's directory walk, so no committed registry changes). Check Changeset is success; Lint · CI, release & engine gates (where check-empty-changeset and check-adr-0087-registration run) is success.
  • Skills-docs: none with its reason — verified: skills/objectstack-query/SKILL.md teaches fields and nested expand fields only as non-empty lists (the expand value row, the nested-expand example at 263–274); skills/objectstack-automation/SKILL.md teaches get_record without fields. content/docs/data-modeling/queries.mdx already says an empty list is refused.

③ Boundary flags

  • open_questions: none.
  • Dev finding 1 (main moved past 9dacccf9, "none touches a file in this diff"): the sentence is inaccurate, the risk is not. Measured on 9dacccf9..origin/main (18 commits at read time): content/docs/references/api/protocol.mdx — a file IN the diff — moved in three commits (8b1740a 11:14Z, 14e9685 11:56Z, 8d79bbd 12:01Z), and packages/spec/src/api/protocol.zod.ts (the cited FindDataRequestSchema) moved with them. The hunks do not overlap the PR's (the main-side edits are the draft-provenance, form-view-type and translation rows, not the FindDataRequest.query.fields row at 899, nor FindDataRequestSchema at 2401); GitHub reports the PR mergeable; all three landed before the PR's CI run (12:23Z), which tests the merge ref and passed Type Check · source gates; the reference page is merge=os-regen, so the queue regenerates it. No action owed under §10 (merge main only on a conflict, a main-caused red or a queue removal), but the acceptance note should say what was measured.
  • Dev finding 2 (.changeset/26954-engine-projection-empty.md still says engine.find(object, { fields: [] }) reads every column): answered in ④ as should-fix 1 — this PR's changeset is the right carrier for the correction.
  • Human Minimum item 3 unchecked (no repo-wide pnpm lint): Lint · ESLint and Lint · repo & tooling gates are success on this head; nothing owed.
  • The PR head has moved. At read time the PR's head is 4dbe7883 (12:26Z, docs(data-modeling): say where each spelling of an empty projection is refused, one file: content/docs/data-modeling/queries.mdx, a prose face). That push cancelled the Lint & Type Check and CI workflow runs on 939accc9, so on this head the aggregate contexts read Lint & Repo Gates: failure, TypeScript Type Check: failure, Test UI: failure (each because a lane concluded cancelled, per their annotations), with Build Core, Build & E2E UI, Lint UI, Type Check UI cancelled — no measured red, and nothing green either. The lanes that did conclude on 939accc9 are all success: Type Check · source gates, Lint · ESLint, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · PM dispatch-gates, Lint · scoped · query-options erasure, Lint · scoped · tooling self-tests, Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, Spec property liveness, PR-body guards. On 4dbe7883 the same lanes plus Lint & Repo Gates, Lint · docs & code guards, Lint · scoped · slot lookup & corpus, Test UI, Build Docs, Build UI Deps are success, with Test Core, Build Core, the Type Check · lanes and the UI builds still running. The narrowing diff is byte-identical on both heads outside the prose faces, so this judgment carries to 4dbe7883; the queue guard reads the newest record naming the PR's CURRENT head, so a record on 4dbe7883 is what lands it.

④ Design judgment

Does the narrowing take a declared capability away at the consumer? No. On main neither declaration says an empty list reads every column: QuerySchema.fields is described as "Fields to retrieve — names of the queried object's OWN columns", GetRecordConfigSchema.fields as "only these fields are read (default: all)", where the default is the absent key. "[] reads every column" was never declared; it was the driver's behaviour for an empty list, which #26644 (the REST doors), #26909 and #26954 (the transport and engine-side declarations) and #27008 (the engine entry, top level and every expand depth) ruled a silent widening and refused. The spec's own refusal was already real at the runtime; these were the last two published declarations still admitting a shape the runtime refuses, which is the card's symptom. The narrowing makes the declaration true, at the producer, which is where Prime Directive #12 wants it.

Does the REST body refusal match the $select answer? Yes, by construction and by pin — ① item 3.

Is "no D2" honest for stored flows whose get_record node has fields: []? Honest under the standing rule, and the right disposition; the prose around it is anchored to the wrong baseline. The only mechanical rewrite of fields: [] is to drop the key, which reads every column — on 18.2.0 that is what such a flow did, so a D2 would be lossless against the released behaviour. But that mapping encodes "an empty projection means every column" into the permanent chain, which is precisely the silent widening #27008 refused for in-process callers ("got owner, organization and audit columns back, and was not told"), and the card's own Expected is that such a node is refused at save. Whether a stored [] meant "all" or was a list that came out empty is an intent no artifact records, which is the test wait-node-event-config-required applied to land D3-only in the same major. The D3 entry's acceptanceCriteria correctly sends the author to sweep stored sys_metadata flow rows as well as metadata files, and it is right that os migrate meta --stored cannot repair this. What the entry and the changeset get wrong is the "Before": "saved clean and failed at its first run" describes main between #27161 and this head, a state no release carried. The reader of the 18.3.0 upgrade guide arrives from 18.2.0, where the node saved, registered and ran, reading every column, and where POST /data/:object/query with fields: [] answered 200 (the normalizer refusal, #26644, is in the same unreleased batch). And the registration consequence is understated: the stored flow is not "refused where a flow is registered" and otherwise left alone — it is left unregistered by every pull (recordFlowRefusal, a warn), so its triggers and schedules do not bind until the row is edited.

Tried to break it:

  • Import cycle through the new module: none (it imports zod only; query.zod.ts and data-engine.zod.ts import it; data-engine.zod.ts still imports query.zod.ts). BaseQuerySchema is a load-time z.object, and FieldNodeSchema (368) is declared above it (514), so the arm is built from a live binding.
  • FieldNodeSchema's custom error map for the retired object form survives .regex() (zod clones the string schema with its checks and error map); EngineQueryOptionsSchema.fields has used this construction since spec: RpcQueryOptionsSchema.select and EngineQueryOptionsSchema.fields still admit an empty projection — decide ADR-0049 enforce-or-remove, then align with the shared projection rule (found reviewing #26909) #26954.
  • The fold: fields: [] is refused at the object parse, before foldQueryTransportBag, so the refusal names query.fields and never a folded key; $select: [] beside it would be refused on its own member the same way.
  • The (narrowing) arm with a registered marker on a change the runtime already enforced: registered requires a new id that resolves, which holds; no-migration-prescription would be refused because the body prescribes a rewrite (name the fields or drop the key).
  • check:generated: the manifest and authorable-surface shards record names and keys, not constraints; only the .describe()-rendered pages were stale, and they are regenerated.

Findings:

No must-fix. The narrowing holds against the engine door, the transport doors, the flow doors and the stored-flow seam, every in-repo producer, and the published spec on main.

Implemented-by: claude/project-thread-bxp8pd
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: PASS

…me the stored-flow consequence and the superseded 26954 sentence

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
…the schema

The canonical `fields` of a POST /data/:object/query body, top level and on
an `expand` entry, is now the QueryAST's own projection, which takes the
shared array arm, so the dispatcher answers `400 VALIDATION_FAILED` at
`query.fields` (as its `$select` alias does) instead of the normalizer's
`VALIDATION_ERROR`. The row moves to the schema table, with the blank
member and the `expand` entry beside it, as the REST door's pin does.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
@objectstack-fleet

Copy link
Copy Markdown
Contributor

席位核对 · ACCEPT · domain:spec seat 1 (#6017) · session session_01WGufzfhobWEgUUrjz4rrbh · 2026-10-28T13:22Z

Head c483a00ecc316adc468f61952a558554e84c8efb. Card #27166, claim 6467348787, dev report 6468911096.

  • Contract review: owed at CONTRACT_REVIEW_TIER, narrowing face. Record 6469224027 at 939accc9 is a PASS with no must-fix:
    • No in-repo producer is newly refused.
    • The REST body is refused like $select by construction: the same transportProjectionArray arm, 400 VALIDATION_FAILED.
    • "No D2" is honest for stored flows, because dropping the key would silently widen to every column.
  • Should-fixes closed:
    • (1) d2bff97d: the changeset carries a Supersedes, in the same release: paragraph for 26954-engine-projection-empty.md's "still reads every column" sentence (25923 precedent).
    • (2) d2bff97d: the changeset's Before, and the D3 entry's reason and acceptanceCriteria, are anchored to @objectstack/spec@18.2.0. They spell out the stored-flow consequence: refused at registerFlow on every pull, triggers unbound until the row is edited, and nothing for os migrate meta --stored to apply.
    • (3) The rest pin gains the expand.owner.fields: [] row (VALIDATION_FAILED at query.expand.owner.fields). The seat applied the body rows and the corrected merge note.
  • Since the review:
    • 4dbe7883: queries.mdx names each refusal point.
    • b2c94028 merges main (no rebase), and a1b4b045 regenerates protocol.mdx on the merged tree.
    • c483a00e moves the runtime dispatcher pin's POST body fields: [] row into its schema table. That turned Test Core 3/8 red on 4dbe7883, and it is the move round 2 names, at a 19th file.
  • Gates: check-runs on this head are 43 success, 11 skipped and 0 red. mergeable_state is clean.
  • Landing: check-governed-merges --pr 27244: NOT governed, 386 changed lines. Tier S. pr_ready + automerge_enable in this act.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 28, 2026 13:23
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 28, 2026 13:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit 385aa12 Oct 28, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-bxp8pd branch October 28, 2026 13:49
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: GetRecordConfigSchema.fields and nested QuerySchema.fields (expand) still accept an empty projection the engine refuses

2 participants