From 498fe4ed572959518d00136d087275f347b287c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:10:19 +0000 Subject: [PATCH 1/8] wip(spec): QuerySchema.fields and GetRecordConfigSchema.fields refuse an empty projection Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT --- .../data-empty-projection-count-door.test.ts | 24 +-- ...t-record-id-projection.integration.test.ts | 12 +- .../src/automation/builtin-node-config.zod.ts | 12 +- packages/spec/src/data/data-engine.zod.ts | 9 +- .../src/data/projection-names-a-field.test.ts | 149 ++++++++++++++++++ .../spec/src/data/projection-names-a-field.ts | 26 +++ packages/spec/src/data/query.zod.ts | 3 +- ...and-get-record-empty-projection-refused.ts | 51 ++++++ 8 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 packages/spec/src/data/projection-names-a-field.test.ts create mode 100644 packages/spec/src/data/projection-names-a-field.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.query-and-get-record-empty-projection-refused.ts diff --git a/packages/rest/src/data-empty-projection-count-door.test.ts b/packages/rest/src/data-empty-projection-count-door.test.ts index 07e84a19a5e..01ac5eb719a 100644 --- a/packages/rest/src/data-empty-projection-count-door.test.ts +++ b/packages/rest/src/data-empty-projection-count-door.test.ts @@ -13,7 +13,7 @@ * |:--|:--|:--| * | `GET ?$select=`, `?$select=%20`, `?$select=,`, `?select=`, `?fields=` | 200, every column | 400 naming the spelling | * | `GET ?$count=maybe`, `?$count=1`, `?count=` (with `$top`) | 200, `total` reported | 400 naming the spelling | - * | `POST {"select":""}`, `{"fields":[]}` | 200, every column | 400 naming the key (`select` at the body schema, `VALIDATION_FAILED`, since #26909) | + * | `POST {"select":""}`, `{"fields":[]}` | 200, every column | 400 naming the key at the body schema, `VALIDATION_FAILED` (`select` since #26909, `fields` since #27166) | * | `POST {"count":"maybe"}` | 400 `VALIDATION_FAILED` (the body schema) | unchanged | * | `GET /:id?select=`, `?select=,` | 200, every column | `VALIDATION_ERROR` naming `select`, at this route's 404 (it rewrote every 400, as for `?select=nope`); 400 since #26816 | * | `GET ?$select=label`, `?$count=true` / `false`, absent; `GET /:id?select=label` | 200 | unchanged | @@ -57,11 +57,6 @@ const REFUSED_GET: ReadonlyArray, strin ['count= blank', { $top: '2', count: '' }, 'count', 'must be true or false'], ]; -/** name · the JSON body `POST /data/:object/query` receives · the key the refusal names. */ -const REFUSED_POST: ReadonlyArray, string]> = [ - ['fields: []', { fields: [] }, 'fields'], -]; - /** * [#26909] The body's published schema (`FindDataRequestSchema`) now declares * the projection the normalizer enforces, so these are refused where every @@ -74,6 +69,10 @@ const REFUSED_POST_AT_SCHEMA: ReadonlyArray { - const before = reads.n; - for (const [name, body, param] of REFUSED_POST) { - const res = await query(body); - expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(400); - expect(res.body.code, name).toBe('VALIDATION_ERROR'); - expect(res.body.error, name).toContain(`Query parameter '${param}' names no field`); - } - expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); - }); - - it('POST query: `select` / `$select` naming no field is the schema door\'s 400 VALIDATION_FAILED — no read', async () => { + it('POST query: `fields` / `select` / `$select` naming no field is the schema door\'s 400 VALIDATION_FAILED — no read', async () => { const before = reads.n; for (const [name, body, field] of REFUSED_POST_AT_SCHEMA) { const res = await query(body); diff --git a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts index 70178ecc88a..420246c3ed2 100644 --- a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts @@ -15,8 +15,9 @@ * - a projection that already names `id` is handed over as written (no * second `id`); * - no projection stays a whole-row read (no projection is invented); - * - an empty projection is handed over as written, and the engine refuses it - * (#27008): the node fails with that refusal carried, never a whole-row read; + * - an empty projection is refused by the node's own config contract + * (`GetRecordConfigSchema.fields`, #27166), so the engine is never asked: + * the node fails before any read, never a whole-row read; * - the `find` branch (`limit > 1`) declares no top-level `id` and is not * touched. * @@ -161,18 +162,17 @@ describe('get_record answers the row id as output.id whatever its fields project expect(result.output).toEqual({ record: whole, id: rowId, object: OBJECT.name }); }); - it("empty fields: handed to the engine as written, and the node fails with the engine's refusal carried (#27008)", async () => { + it("empty fields: refused by the node's config contract before any read (#27166)", async () => { handed.length = 0; const result = await getRecord.execute( { id: 'read', type: 'get_record', config: { objectName: OBJECT.name, filter: { title: 'alpha' }, fields: [] } }, new Map(), { runAs: 'system', flowName: 'pin_get_record_id' }, ); - expect(handed, 'the node hands the empty projection over unchanged, once').toEqual([{ method: 'findOne', fields: [] }]); + expect(handed, 'the engine is never asked').toEqual([]); expect(result.success, 'an empty projection is refused, never read as the whole row').toBe(false); expect(result.output).toBeUndefined(); - expect(result.innerRefusal).toMatchObject({ code: 'VALIDATION_ERROR' }); - expect(result.error).toMatch(/^get_record\(pin_get_record_id\) failed: ObjectQL\.findOne\('pin_get_record_id'\) names no field in 'fields'/); + expect(result.error).toContain("get_record 'read': config does not satisfy the get_record contract — fields: Name at least one field."); }); it('a downstream update_record filtering on read.id updates the row the narrowed get_record read', async () => { diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 4f9b45b9978..f878eaf3ca0 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -99,6 +99,7 @@ import { z } from 'zod'; import { EvaluatedExpressionSchema } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; +import { transportProjectionArray } from '../data/projection-names-a-field'; import { refuseCatchallProtoKey, refuseRecordProtoKey } from '../shared/record-proto-key-guard'; import { flowFilterOperands, isExpressionEnvelopeShaped } from './flow-node-expression-paths'; // [#19939] The retirement of the `{…}` template dialect from value slots — the @@ -622,9 +623,14 @@ export const GetRecordConfigSchema = lazySchema(() => strictObject({ objectName: z.string().describe('Object to query'), /** Field/value pairs to match; each operand an envelope, a query placeholder or a literal ({@link flowFilterSlotSchema}). */ filter: flowFilterSlotSchema(`Field/value pairs to match: ${FILTER_OPERAND_DESCRIPTION}`).optional(), - /** Field projection — only these fields are read (default: all). */ - fields: z.array(z.string()).optional() - .describe('Field projection — only these fields are read (default: all)'), + /** + * Field projection — only these fields are read; omit it to read all. At + * least one name, none blank: the executor hands it to `data.find` / + * `findOne` as written, and the engine refuses an empty projection there + * (#27166), so the list is refused at save instead of at run. + */ + fields: transportProjectionArray(z.string()).optional() + .describe('Field projection — only these fields are read; omit it to read all. An empty list or a blank name is refused'), /** * Max records — a positive integer; >1 returns a `records` list, otherwise a * single `record`. The executor hands it to `data.find` as the query's LIMIT diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index 028003d2bda..81651f409bf 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -5,6 +5,7 @@ import { FilterConditionSchema, FilterArraySchema, parseFilterAST } from './filt import type { FilterArray, FilterCondition } from './filter.zod'; import { SortNodeSchema, QuerySchema, FullTextSearchSchema, FieldNodeSchema, AggregationNodeSchema, GroupByNodeSchema, QUERY_CURSOR_REMOVED, QUERY_DISTINCT_REMOVED } from './query.zod'; import type { QueryAST, QueryInput } from './query.zod'; +import { PROJECTION_NAMES_NO_FIELD, transportProjectionArray } from './projection-names-a-field'; import { retiredKey } from '../shared/retired-key'; import { ExecutionContextSchema } from '../kernel/execution-context.zod'; @@ -102,11 +103,13 @@ export const BaseEngineOptionsSchema = lazySchema(() => z.object({ * too. It is declared ahead of both because * `RpcQueryOptionsSchema` extends `EngineQueryOptionsSchema` at module load, * which runs the lazy factory before any later `const` exists. + * + * [#27166] The array arm and its sentence live in + * `./projection-names-a-field.ts`, because `QuerySchema.fields` (and so every + * nested `expand` value) and the flow `get_record` node's `fields` take it too, + * and `./query.zod.ts` cannot import this module. */ -const PROJECTION_NAMES_NO_FIELD = 'Name at least one field'; const TransportProjectionStringSchema = z.string().regex(/[^\s,]/, PROJECTION_NAMES_NO_FIELD); -const transportProjectionArray = (member: T) => - z.array(member.regex(/\S/, 'A field name cannot be blank')).min(1, PROJECTION_NAMES_NO_FIELD); const PROJECTION_ACCEPT_SET = 'at least one field name; a value naming no field (blank, only commas and spaces, ' + 'an empty list, or a list with a blank name) is refused'; diff --git a/packages/spec/src/data/projection-names-a-field.test.ts b/packages/spec/src/data/projection-names-a-field.test.ts new file mode 100644 index 00000000000..c422bc8cc2b --- /dev/null +++ b/packages/spec/src/data/projection-names-a-field.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27166] The QueryAST's own `fields` (and so every nested `expand` value) + * and the flow `get_record` node's `fields` take the projection's array arm + * the other published declarations take: at least one name, none blank. The + * in-process engine refuses an empty projection at its `find` / `findOne` + * entry, top level and at any `expand` depth (#27008), and the `get_record` + * executor hands its authored `fields` to that entry as written, so a + * declaration admitting `[]` admitted a shape the runtime refuses. + * + * Every refusal is pinned against the sentence `EngineQueryOptionsSchema.fields` + * gives for the same value, so the declarations cannot drift apart. + */ + +import { describe, it, expect } from 'vitest'; +import { QuerySchema } from './query.zod'; +import { EngineQueryOptionsSchema, QueryTransportParamsSchema, QueryWithTransportSchema } from './data-engine.zod'; +import { GetRecordConfigSchema } from '../automation/builtin-node-config.zod'; +import { flowNodeConfigRefusals } from '../automation/flow-node-config-refusals'; +import { defineStack } from '../stack.zod'; + +type Path = ReadonlyArray; + +const REFUSED: ReadonlyArray = [ + ['an empty list', [], []], + ['a list with a blank name', ['name', ''], [1]], + ['a list with a whitespace name', ['name', ' '], [1]], +]; +const KEPT: ReadonlyArray = [ + ['one name', ['name']], + ['two names', ['name', 'email']], +]; + +/** The first issue a schema raises for `input`, or a failed expectation when it parses. */ +function firstIssue(schema: { safeParse(v: unknown): { success: boolean; error?: { issues: Array<{ message: string; path: PropertyKey[] }> } } }, input: unknown) { + const parsed = schema.safeParse(input); + expect(parsed.success, `refuses ${JSON.stringify(input)}`).toBe(false); + return parsed.error!.issues[0]!; +} + +/** The sentence the engine-side declaration gives for the same projection value. */ +const engineMessage = (value: string[]) => firstIssue(EngineQueryOptionsSchema, { fields: value }).message; + +describe('QuerySchema.fields admits exactly what EngineQueryOptionsSchema.fields admits', () => { + it.each(REFUSED)('refuses %s at the top level, in the engine declaration\'s words', (_name, value, tail) => { + const issue = firstIssue(QuerySchema, { object: 'task', fields: value }); + expect(issue.path).toEqual(['fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('refuses %s on an expand entry, naming the entry', (_name, value, tail) => { + const issue = firstIssue(QuerySchema, { object: 'task', fields: ['title', 'project_id'], expand: { project_id: { object: 'project', fields: value } } }); + expect(issue.path).toEqual(['expand', 'project_id', 'fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('refuses %s on a second-level expand entry', (_name, value, tail) => { + const query = { + object: 'task', + expand: { project_id: { object: 'project', expand: { org_id: { object: 'org', fields: value } } } }, + }; + const issue = firstIssue(QuerySchema, query); + expect(issue.path).toEqual(['expand', 'project_id', 'expand', 'org_id', 'fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('EngineQueryOptionsSchema.expand refuses %s on an entry, as the engine entry does', (_name, value, tail) => { + const issue = firstIssue(EngineQueryOptionsSchema, { expand: { project_id: { object: 'project', fields: value } } }); + expect(issue.path).toEqual(['expand', 'project_id', 'fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('the transport-aware query slot refuses canonical `fields` %s as it refuses `$select`', (_name, value, tail) => { + const issue = firstIssue(QueryWithTransportSchema, { object: 'task', fields: value }); + expect(issue.path).toEqual(['fields', ...tail]); + expect(issue.message).toBe(firstIssue(QueryTransportParamsSchema, { $select: value }).message); + }); + + it.each(KEPT)('keeps %s unchanged, at the top level and on an expand entry', (_name, value) => { + const query = { object: 'task', fields: value, expand: { project_id: { object: 'project', fields: value } } }; + const parsed = QuerySchema.parse(query); + expect(parsed.fields).toEqual(value); + expect(parsed.expand?.project_id?.fields).toEqual(value); + }); + + it('keeps an absent projection: absent still reads every column', () => { + const parsed = QuerySchema.parse({ object: 'task', expand: { project_id: { object: 'project' } } }); + expect(parsed.fields).toBeUndefined(); + expect(parsed.expand?.project_id?.fields).toBeUndefined(); + }); +}); + +describe('GetRecordConfigSchema.fields: an empty projection is refused at save, not at run', () => { + const config = (fields: unknown) => ({ objectName: 'gr_task', filter: { title: 'alpha' }, fields }); + + it.each(REFUSED)('refuses %s, in the engine declaration\'s words', (_name, value, tail) => { + const issue = firstIssue(GetRecordConfigSchema, config(value)); + expect(issue.path).toEqual(['fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('the save door\'s node judge refuses %s at `fields`, naming the blank member', (_name, value, tail) => { + const at = `fields${tail.map((i) => `[${i}]`).join('')}`; + expect(flowNodeConfigRefusals('get_record', config(value)).map(({ code, params, path }) => ({ code, params, path }))).toEqual([ + { code: 'node-config-refused-by-contract', params: { nodeType: 'get_record', key: at }, path: at }, + ]); + }); + + it('defineStack refuses a get_record node with `fields: []` at its config path', () => { + const stack = { + manifest: { id: 'com.example.projection', name: 'projection', version: '1.0.0', type: 'app', namespace: 'gr' }, + objects: [{ name: 'gr_task', label: 'Task', fields: { title: { type: 'text', label: 'Title' } } }], + flows: [{ + name: 'gr_read', + label: 'Read', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'read', type: 'get_record', label: 'Read', config: config([]) }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'read' }, + { id: 'e2', source: 'read', target: 'end' }, + ], + }], + }; + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[] }> } | undefined; + try { + defineStack(stack as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse the flow').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal!.issues!.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.fields']); + }); + + it.each(KEPT)('keeps %s unchanged, and the node judge finds nothing', (_name, value) => { + expect(GetRecordConfigSchema.parse(config(value)).fields).toEqual(value); + expect(flowNodeConfigRefusals('get_record', config(value))).toEqual([]); + }); + + it('keeps an absent projection: the node reads every field', () => { + expect(GetRecordConfigSchema.parse({ objectName: 'gr_task' }).fields).toBeUndefined(); + expect(flowNodeConfigRefusals('get_record', { objectName: 'gr_task' })).toEqual([]); + }); +}); diff --git a/packages/spec/src/data/projection-names-a-field.ts b/packages/spec/src/data/projection-names-a-field.ts new file mode 100644 index 00000000000..3b53a17958d --- /dev/null +++ b/packages/spec/src/data/projection-names-a-field.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * The array arm of the projection rule every published declaration of a + * read's projection shares: a list names at least one field, and no member is + * blank. Absent means "every column", so an empty projection read as absent is + * a widening; it is refused instead, in one sentence. + * + * Declared in its own module, outside the `data` barrel, because its readers + * sit on both sides of an import edge: `./query.zod.ts` (the QueryAST's own + * `fields`, and so every nested `expand` value), `./data-engine.zod.ts` (the + * transport and engine-side spellings, which import `./query.zod.ts`) and + * `../automation/builtin-node-config.zod.ts` (the flow `get_record` node's + * `fields`). The accept set and its history are documented beside the + * transport arms in `./data-engine.zod.ts`. + * + * [#27166] The QueryAST and `get_record` declarations joined once the + * in-process engine refused an empty projection at its `find` / `findOne` + * entry, top level and at any `expand` depth. + */ +export const PROJECTION_NAMES_NO_FIELD = 'Name at least one field'; + +export const transportProjectionArray = (member: T) => + z.array(member.regex(/\S/, 'A field name cannot be blank')).min(1, PROJECTION_NAMES_NO_FIELD); diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index d3f37aa2d2c..fd0c6c81270 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -6,6 +6,7 @@ import { FilterConditionSchema } from './filter.zod'; import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; +import { transportProjectionArray } from './projection-names-a-field'; /** * QueryAST — Abstract Syntax Tree for data queries. @@ -516,7 +517,7 @@ const BaseQuerySchema = z.object({ object: z.string().describe('Object name (e.g. account)'), /** Select Clause */ - fields: z.array(FieldNodeSchema).optional().describe("Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes."), + fields: transportProjectionArray(FieldNodeSchema).optional().describe("Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes."), /** Where Clause (Filtering) */ where: FilterConditionSchema.optional().describe('Filtering criteria (WHERE)'), diff --git a/packages/spec/src/migrations/entries/semantic/18.query-and-get-record-empty-projection-refused.ts b/packages/spec/src/migrations/entries/semantic/18.query-and-get-record-empty-projection-refused.ts new file mode 100644 index 00000000000..edddefced95 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.query-and-get-record-empty-projection-refused.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The last two declarations of a read's projection that admitted an empty +// list. No D2 conversion: the only mechanical rewrite of `fields: []` is to +// drop the key, which reads every column, the widening the rule refuses. +export const entry: SemanticMigration = { + id: 'query-and-get-record-empty-projection-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'QuerySchema.fields (the QueryAST projection, and so every nested expand entry, including ' + + 'EngineQueryOptionsSchema.expand and the canonical fields of a POST /data/:object/query body) and ' + + 'a flow get_record node\'s config.fields (GetRecordConfigSchema), authored as an EMPTY list or a ' + + 'list with a blank or whitespace-only name', + replacement: + 'name the fields to read (`fields: [\'title\', \'status\']`), or omit `fields` to read every ' + + 'field. On an `expand` entry the same holds for the related record: name its fields, or omit ' + + '`fields` on that entry. ⛔ There is no replacement that can be derived from what was written: ' + + 'an empty list names no field, and reading it as every field is exactly the widening the rule ' + + 'refuses, so the platform must not pick for the author.', + reason: + 'One rule for every published declaration of a read\'s projection: a projection names at least ' + + 'one field, none blank, and an empty one is refused rather than read as every column. The REST ' + + 'doors, the transport spellings (select, $select) and the engine-side declarations ' + + '(EngineQueryOptionsSchema.fields and its RPC and legacy siblings) already refused it, and the ' + + 'in-process engine refuses it at its find / findOne entry, top level and at any expand depth. Two ' + + 'declarations still admitted it: the QueryAST\'s own fields, which is the value type of every ' + + 'expand entry, and the get_record node\'s fields, which the executor hands to the engine as ' + + 'written. So a get_record node with fields: [] saved clean and failed at its first run with the ' + + 'engine\'s refusal, and an expand entry with fields: [] validated against the declaration the ' + + 'engine then refused. Both now take the same array arm, with the same sentence ("Name at least ' + + 'one field"; "A field name cannot be blank" for a blank member). A side effect at the REST door: ' + + 'a POST /data/:object/query body whose canonical fields is empty is now refused by the body ' + + 'schema (400 VALIDATION_FAILED at query.fields), the answer its $select alias already got, ' + + 'instead of by the normalizer (400 VALIDATION_ERROR). Either way it was refused; only the code ' + + 'moves. Stored flows are not rewritten and there is no D2 conversion: the only mechanical ' + + 'rewrite is dropping the key, which reads every column. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'Search flow definitions (metadata files and stored sys_metadata flow rows) for a get_record node ' + + 'whose config.fields is an empty list or carries a blank name, and every query you build in code ' + + '(engine.find / findOne options, an expand entry at any depth, a POST /data/:object/query body) ' + + 'for the same. Name the fields, or remove the key. The sweep is mechanical for authored ' + + 'metadata: os validate and defineStack refuse such a node at flows.N.nodes.M.config.fields ' + + '(STACK_SCHEMA_INVALID), registerFlow refuses it when the flow is registered, and ' + + 'QuerySchema.safeParse / GetRecordConfigSchema.safeParse answer one issue at the fields path ' + + '(or fields.N for a blank member) reading "Name at least one field". An absent fields and a ' + + 'non-empty list parse unchanged. The repository carried no authored empty projection on either ' + + 'shape at the time of the change, outside the one test that pinned the old run-time failure.', +}; From bd35a3fab0db0966d3d1abac163b7c94755e669a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:13:28 +0000 Subject: [PATCH 2/8] fix(spec,service-automation)!: QuerySchema.fields and the get_record 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 Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT --- ...27166-query-get-record-empty-projection.md | 21 +++++++++++++++++++ content/docs/references/api/contract.mdx | 4 ++-- content/docs/references/api/protocol.mdx | 2 +- .../automation/builtin-node-config.mdx | 2 +- content/docs/references/data/data-engine.mdx | 10 ++++----- content/docs/references/data/query.mdx | 2 +- .../builtin-node-form-zod-ledger.test.ts | 16 ++++++++++++++ .../src/builtin/crud-nodes.ts | 7 +++++-- 8 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 .changeset/27166-query-get-record-empty-projection.md diff --git a/.changeset/27166-query-get-record-empty-projection.md b/.changeset/27166-query-get-record-empty-projection.md new file mode 100644 index 00000000000..5ba1fdbb1f4 --- /dev/null +++ b/.changeset/27166-query-get-record-empty-projection.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +--- + +fix(spec)!: `QuerySchema.fields` (and so every nested `expand` entry) and the flow `get_record` node's `fields` refuse an empty projection, as every other published projection declaration and the in-process engine do (#27166) + +Clause-②: no (narrowing) — two published declarations of a read's projection, `QuerySchema.fields` (the QueryAST, which is also the value type of every `expand` entry, `EngineQueryOptionsSchema.expand` included, and the canonical `fields` of the transport-aware query slot) and `GetRecordConfigSchema.fields` (the flow `get_record` node), refuse a list naming no field, which they admitted before. Nothing is widened and no key is added, removed or renamed. +Metadata-protocol: narrowed +Skills-docs: none — `skills/**` teaches `fields` and nested `expand` `fields` only as non-empty lists (`skills/objectstack-query/SKILL.md`, the `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. + + + +**BREAKING** for a flow `get_record` node, or a query you build, that carries an empty projection. The in-process engine already refuses `fields: []` at its `find` / `findOne` entry, top level and at any `expand` depth; these two declarations still admitted it, so such a `get_record` node saved clean and failed at its first run. + +- **Before.** `QuerySchema.fields` and `GetRecordConfigSchema.fields` admitted any list of strings: `[]`, and a list with a blank or whitespace-only name. A `get_record` node with `fields: []` passed `os validate`, `defineStack` and `registerFlow`, and its run failed with the engine's refusal. A `POST /data/:object/query` body with `fields: []` was refused by the Data API's normalizer (`400 VALIDATION_ERROR`). +- **Now.** A list must hold at least one name, and no name may be blank (JSON Schema `minItems: 1`, and a non-blank pattern on each name). Both read the rule from the declaration `EngineQueryOptionsSchema.fields` and `QueryTransportParamsSchema.$select` read, so the refusal says the same thing: "Name at least one field", or "A field name cannot be blank" for a blank member. A `get_record` node with `fields: []` is refused where a flow is saved or registered, at `flows.N.nodes.M.config.fields` (`STACK_SCHEMA_INVALID` from `defineStack`), and its executor refuses the config before any read. An `expand` entry with `fields: []` is refused at `expand..fields`. A `POST /data/:object/query` body with `fields: []` is now refused by the body schema, `400 VALIDATION_FAILED` at `query.fields`, which is the answer its `$select: []` spelling already got. +- **`@objectstack/service-automation`.** The `get_record` node's designer form (`configSchema`) declares `fields` with `minItems: 1`, as its `limit` already mirrors the contract's floor, so the form no longer offers an empty list the save refuses. +- **Unchanged.** The inferred types (`string[]`), a named projection, and leaving `fields` out to read every column. Stored flows are not rewritten. + +**The remedy.** Name the fields to read, or remove `fields` to read every field; on an `expand` entry, the same for the related record. There is no mechanical rewrite: dropping the key reads every column, which is the widening the rule refuses, so the choice is the author's. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 26074cf317d..a8b16daab90 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -580,7 +580,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -633,7 +633,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index e5cbe81cb32..db40991bccf 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -899,7 +899,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 09afddca6b3..3baea037e0c 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -232,7 +232,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | :--- | :--- | :--- | :--- | | **objectName** | `string` | ✅ | Object to query | | **filter** | `Record` | optional | Field/value pairs to match: each operand — a field's value, an operator's comparand (`{"$ne": null}`), a list comparand's element — is a CEL value envelope `{ dialect: 'cel', source }` evaluated before the query (one that evaluates to null refuses the node), a query placeholder such as `{current_user_id}` or `{today}` the query engine resolves, or a literal; a flow `{…}` template token is refused | -| **fields** | `string[]` | optional | Field projection — only these fields are read (default: all) | +| **fields** | `string[]` | optional | Field projection — only these fields are read; omit it to read all. An empty list or a blank name is refused | | **limit** | `integer` | optional | Max records to return — a positive integer; >1 switches to a multi-record query | | **outputVariable** | `string` | optional | Flow variable the result is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index 3a6c3d7e689..88923ae3886 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -1185,7 +1185,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1324,7 +1324,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1364,7 +1364,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1433,7 +1433,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1455,7 +1455,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | diff --git a/content/docs/references/data/query.mdx b/content/docs/references/data/query.mdx index ce28a4101b3..a7572560848 100644 --- a/content/docs/references/data/query.mdx +++ b/content/docs/references/data/query.mdx @@ -129,7 +129,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | diff --git a/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts b/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts index 903515f7ba4..8921e6d09f0 100644 --- a/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts +++ b/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts @@ -154,6 +154,22 @@ describe('CRUD / screen / map form ↔ Zod reconciliation (#4045)', () => { expect(parse(0), 'the contract refuses 0').toBe(false); }); + it('get_record.fields: the form\'s minItems is the contract\'s refusal boundary', () => { + // [#27166] The contract refuses an empty projection at every save door; a + // form that offers an empty list offers a value the save always refuses. + const fields = formSchema('get_record').properties?.fields as + | { type?: string; minItems?: number } + | undefined; + expect(fields?.type).toBe('array'); + expect(fields?.minItems).toBe(1); + + const parse = (value: string[]) => + (GetRecordConfigSchema as unknown as { safeParse(v: unknown): { success: boolean } }) + .safeParse({ objectName: 'account', fields: value }).success; + expect(parse(['name']), 'the contract accepts one name').toBe(true); + expect(parse([]), 'the contract refuses an empty list').toBe(false); + }); + describe('assignment: pinned as un-reconcilable, with the reason on record', () => { // `assignment` deliberately has NO entry in builtin-node-config.zod.ts: with // no `assignments` wrapper, the TOP-LEVEL config keys ARE the author's diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index 64d295900c7..66630adad9c 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -474,9 +474,12 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): // Declared in #4045: the executor has always passed this // straight into find/findOne as the projection, but no // form offered it — authorable only by hand until now. + // `minItems: 1` mirrors the contract: an empty + // `GetRecordConfigSchema.fields` is refused at every + // flow save door (#27166), as `limit` below is. fields: { - type: 'array', items: { type: 'string' }, title: 'Fields', - description: 'Field projection — only these fields are read (default: all).', + type: 'array', items: { type: 'string' }, minItems: 1, title: 'Fields', + description: 'Field projection — only these fields are read; leave it out to read all. An empty list is refused.', }, // `minimum: 1` mirrors the contract: `GetRecordConfigSchema.limit` // is a positive integer, refused below 1 at every flow save From 47535913d061f2f00060d751fa1b0da2f019c7b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:25:28 +0000 Subject: [PATCH 3/8] test(spec): the empty-arrays QuerySchema case no longer carries an empty projection Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT --- packages/spec/src/data/query.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/data/query.test.ts b/packages/spec/src/data/query.test.ts index 2ad02daa627..a87b9328e6a 100644 --- a/packages/spec/src/data/query.test.ts +++ b/packages/spec/src/data/query.test.ts @@ -840,10 +840,10 @@ describe('QuerySchema - Edge Cases and Null Handling', () => { it('should handle empty arrays', () => { // `joins: []` / `windowFunctions: []` moved to the retirement pins — the - // tombstones refuse even an empty array (#4286). + // tombstones refuse even an empty array (#4286). `fields: []` moved to + // `projection-names-a-field.test.ts`: an empty projection is refused (#27166). const query: QueryAST = { object: 'account', - fields: [], aggregations: [], groupBy: [], orderBy: [], From 939accc94e44b7765ea07c4f8d887ac382500c29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:35:16 +0000 Subject: [PATCH 4/8] test(service-automation): pin the config refusal's path as the executor prints it Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT --- .../src/builtin/get-record-id-projection.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts index 420246c3ed2..410216c2758 100644 --- a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts @@ -172,7 +172,7 @@ describe('get_record answers the row id as output.id whatever its fields project expect(handed, 'the engine is never asked').toEqual([]); expect(result.success, 'an empty projection is refused, never read as the whole row').toBe(false); expect(result.output).toBeUndefined(); - expect(result.error).toContain("get_record 'read': config does not satisfy the get_record contract — fields: Name at least one field."); + expect(result.error).toContain("get_record 'read': config does not satisfy the get_record contract — config.fields: Name at least one field."); }); it('a downstream update_record filtering on read.id updates the row the narrowed get_record read', async () => { From 4dbe78835b641987a45f7bb6dd286929089fb20c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:26:15 +0000 Subject: [PATCH 5/8] docs(data-modeling): say where each spelling of an empty projection is refused Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT --- content/docs/data-modeling/queries.mdx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index 367cbf3b1be..d3988466838 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -296,10 +296,13 @@ retired, read them with the operator-only them before `os migrate apply --allow-destructive` drops the column. No runtime door serves them. -`fields` names at least one column. An empty list (`fields: []`, `$select=`) is refused with -`400 VALIDATION_ERROR` — on the data API, and by `engine.find()` / `findOne()` for an in-process -caller (a plugin, a hook, a flow), the top-level `fields` and an `expand` entry's alike — rather -than read as "every column". Leave `fields` out to read every column. +`fields` names at least one column. An empty list, or a list with a blank name, is refused +rather than read as "every column", the top-level `fields` and an `expand` entry's alike: by the +query schema itself (`QuerySchema`), so a `POST /data/:object/query` body with `fields: []` answers +`400 VALIDATION_FAILED` naming `query.fields`; on the querystring (`?fields=`, `$select=`) with +`400 VALIDATION_ERROR`; and by `engine.find()` / `findOne()` for an in-process caller (a plugin, a +hook). A flow `get_record` node with `fields: []` is refused when the flow is saved. Leave +`fields` out to read every column. ### Nested / Related Fields From d2bff97ddaf0a1986587c9d3c816794d328a7686 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:42:55 +0000 Subject: [PATCH 6/8] docs(changeset,spec): anchor the empty-projection notes to 18.2.0, name the stored-flow consequence and the superseded 26954 sentence Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT --- ...27166-query-get-record-empty-projection.md | 11 ++++-- .../data-empty-projection-count-door.test.ts | 3 ++ ...and-get-record-empty-projection-refused.ts | 38 +++++++++++-------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/.changeset/27166-query-get-record-empty-projection.md b/.changeset/27166-query-get-record-empty-projection.md index 5ba1fdbb1f4..b911668f982 100644 --- a/.changeset/27166-query-get-record-empty-projection.md +++ b/.changeset/27166-query-get-record-empty-projection.md @@ -11,11 +11,14 @@ Skills-docs: none — `skills/**` teaches `fields` and nested `expand` `fields` -**BREAKING** for a flow `get_record` node, or a query you build, that carries an empty projection. The in-process engine already refuses `fields: []` at its `find` / `findOne` entry, top level and at any `expand` depth; these two declarations still admitted it, so such a `get_record` node saved clean and failed at its first run. +**BREAKING** for a flow `get_record` node, or a query you build, that carries an empty projection. In this same release the in-process engine starts refusing `fields: []` at its `find` / `findOne` entry, top level and at any `expand` depth; without this change the two declarations below would still admit it, so such a `get_record` node would save clean and fail at its first run. -- **Before.** `QuerySchema.fields` and `GetRecordConfigSchema.fields` admitted any list of strings: `[]`, and a list with a blank or whitespace-only name. A `get_record` node with `fields: []` passed `os validate`, `defineStack` and `registerFlow`, and its run failed with the engine's refusal. A `POST /data/:object/query` body with `fields: []` was refused by the Data API's normalizer (`400 VALIDATION_ERROR`). -- **Now.** A list must hold at least one name, and no name may be blank (JSON Schema `minItems: 1`, and a non-blank pattern on each name). Both read the rule from the declaration `EngineQueryOptionsSchema.fields` and `QueryTransportParamsSchema.$select` read, so the refusal says the same thing: "Name at least one field", or "A field name cannot be blank" for a blank member. A `get_record` node with `fields: []` is refused where a flow is saved or registered, at `flows.N.nodes.M.config.fields` (`STACK_SCHEMA_INVALID` from `defineStack`), and its executor refuses the config before any read. An `expand` entry with `fields: []` is refused at `expand..fields`. A `POST /data/:object/query` body with `fields: []` is now refused by the body schema, `400 VALIDATION_FAILED` at `query.fields`, which is the answer its `$select: []` spelling already got. +- **Before (`@objectstack/spec@18.2.0`, the last release).** `QuerySchema.fields` and `GetRecordConfigSchema.fields` admitted any list of strings: `[]`, and a list with a blank or whitespace-only name. A `get_record` node with `fields: []` passed `os validate`, `defineStack` and `registerFlow`, and its run read every column of the row. A `POST /data/:object/query` body with `fields: []`, top level or on an `expand` entry, answered `200` with every column. +- **Now.** A list must hold at least one name, and no name may be blank (JSON Schema `minItems: 1`, and a non-blank pattern on each name). Both read the rule from the declaration `EngineQueryOptionsSchema.fields` and `QueryTransportParamsSchema.$select` read, so the refusal says the same thing: "Name at least one field", or "A field name cannot be blank" for a blank member. A `get_record` node with `fields: []` is refused where a flow is saved or registered, at `flows.N.nodes.M.config.fields` (`STACK_SCHEMA_INVALID` from `defineStack`), and its executor refuses the config before any read. An `expand` entry with `fields: []` is refused at `expand..fields`. A `POST /data/:object/query` body with `fields: []` is refused by the body schema, `400 VALIDATION_FAILED` at `query.fields`, which is the answer its `$select: []` spelling gets; an `expand` entry's `fields: []` in that body is refused the same way, at `query.expand..fields`. - **`@objectstack/service-automation`.** The `get_record` node's designer form (`configSchema`) declares `fields` with `minItems: 1`, as its `limit` already mirrors the contract's floor, so the form no longer offers an empty list the save refuses. -- **Unchanged.** The inferred types (`string[]`), a named projection, and leaving `fields` out to read every column. Stored flows are not rewritten. +- **Stored flows.** They are not rewritten, and there is no conversion for `os migrate meta --stored` to apply. A stored flow whose `get_record` node carries `fields: []` is refused by `registerFlow` on every pull of the stored definitions, so it stays unregistered, and its triggers unbound, until the row is edited: name the fields, or remove the key. +- **Unchanged.** The inferred types (`string[]`), a named projection, and leaving `fields` out to read every column. + +**Supersedes, in the same release:** `26954-engine-projection-empty.md` says, under **Unchanged**, that the in-process ObjectQL engine does not parse its options with that schema and that `engine.find(object, { fields: [] })` still reads every column, as before. The engine change in this release (`27008-engine-empty-projection-refused.md`) refuses that call at its `find` / `findOne` entry, so that sentence no longer holds: an empty projection is refused by the declarations and by the engine alike, and leaving `fields` out is the one way to read every column. **The remedy.** Name the fields to read, or remove `fields` to read every field; on an `expand` entry, the same for the related record. There is no mechanical rewrite: dropping the key reads every column, which is the widening the rule refuses, so the choice is the author's. diff --git a/packages/rest/src/data-empty-projection-count-door.test.ts b/packages/rest/src/data-empty-projection-count-door.test.ts index 01ac5eb719a..c6e1265c615 100644 --- a/packages/rest/src/data-empty-projection-count-door.test.ts +++ b/packages/rest/src/data-empty-projection-count-door.test.ts @@ -73,6 +73,9 @@ const REFUSED_POST_AT_SCHEMA: ReadonlyArray.fields), the answer its $select alias ' + + 'gets. Stored flows are not rewritten and there is no D2 conversion: the only mechanical rewrite ' + + 'is dropping the key, which reads every column. A stored flow carrying such a node is therefore ' + + 'refused by registerFlow on every pull of the stored definitions and stays unregistered, its ' + + 'triggers unbound, until the row is edited; os migrate meta --stored has no conversion to apply ' + + 'to it. ADR-0049 / ADR-0087.', acceptanceCriteria: 'Search flow definitions (metadata files and stored sys_metadata flow rows) for a get_record node ' + 'whose config.fields is an empty list or carries a blank name, and every query you build in code ' + '(engine.find / findOne options, an expand entry at any depth, a POST /data/:object/query body) ' - + 'for the same. Name the fields, or remove the key. The sweep is mechanical for authored ' + + 'for the same. Name the fields, or remove the key. For STORED flow rows the search is the ' + + 'catch-all: a stored flow carrying such a node is not registered at boot, and its triggers do ' + + 'not fire, until the row is edited. The sweep is mechanical for authored ' + 'metadata: os validate and defineStack refuse such a node at flows.N.nodes.M.config.fields ' + '(STACK_SCHEMA_INVALID), registerFlow refuses it when the flow is registered, and ' + 'QuerySchema.safeParse / GetRecordConfigSchema.safeParse answer one issue at the fields path ' From a1b4b045f934a9b1723e1ddcc65e649cd0c5640d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:43:44 +0000 Subject: [PATCH 7/8] chore(spec): regenerate the protocol reference page on the merged tree Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT --- content/docs/references/api/protocol.mdx | 56 +++++++++++++++++------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index db40991bccf..70883fe3ee9 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,7 +1,7 @@ --- title: Protocol schema — API Protocol reference navTitle: Protocol -description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 138 more — each property with its type, default and a TypeScript example." +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 139 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilit ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetRecordFieldAccessResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetRecordFieldAccessResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, FlowRegistrationSchema, FlowRegistrationRefusalSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetRecordFieldAccessResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, MetadataAuthoringClientSchema, MetadataAuthoringSurfaceSchema, MetadataConflictErrorSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, FlowRegistration, FlowRegistrationRefusal, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetRecordFieldAccessResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, MetadataAuthoringClient, MetadataAuthoringSurface, MetadataConflictError, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -1458,7 +1458,7 @@ This schema accepts one of the following structures: | **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | -| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'studio' \| 'api'>; actor: string \| null; at: string }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | +| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `GetMetaItemResponse.sortability` @@ -1492,8 +1492,9 @@ One changed key path of the pending draft, and the last write that changed it. | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **path** | `string[]` | ✅ | The changed key path inside `item`: one object key per segment, from the body root down to the deepest key whose value the write changed, added or removed. `["fields", "renewal_count"]` is a field the write added; `["fields", "renewal_count", "label"]` is one property of it. A segment is the key exactly as stored, dots included (a permission set's `fields` map is keyed `object.field`), so a path is never one dotted string. An array is one value: its path ends at its key (`["columns"]`) and never carries an index, which an insert or a reorder would shift. Which elements of an array the draft added or dropped is read by comparing it with the published body's array; the entry answers only who last changed the array as a whole. `[]` is the whole body: the write that created the draft of an item with no published body. | -| **by** | `Enum<'copilot' \| 'studio' \| 'api'>` | ✅ | The authoring surface of the write that last changed this path: the `surface` that save declared (`SaveMetaItemRequestSchema.surface`), `api` when it declared none. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write and a Studio write of the same user carry the same `actor`. | +| **by** | `Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>` | ✅ | The authoring surface of the write that last changed this path: the `surface` that save declared (`SaveMetaItemRequestSchema.surface`), `api` when it declared none. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `mcp` is an AI agent outside the console writing on a person's behalf through the MCP build door; `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write, an MCP write and a Studio write of the same user carry the same `actor`. | | **actor** | `string \| null` | ✅ | The identity that write was recorded under: the save's `actor`, the value its history event carries. `null` when the write recorded none, never a sentinel string. | +| **client** | `{ id: string; name?: string }` | optional | The OAuth client that write was made by: the save's `client`, which only the MCP build door states, so it is present only beside `by: 'mcp'`. ABSENT on every other surface, and on an `mcp` entry whose save stated none: absence means "not recorded", never "no client". | | **at** | `string` | ✅ | When that write happened: an ISO-8601 instant in UTC, written with `Z` (the form `toISOString()` produces). An offset or a local time is refused. | @@ -1788,7 +1789,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | :--- | :--- | :--- | :--- | | **objects** | `Record; … }>` | optional | Object translations keyed by object name | | **picklists** | `Record }>` | optional | Picklist translations keyed by picklist name | -| **apps** | `Record }>` | optional | App translations keyed by app name | +| **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | @@ -1828,9 +1829,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **label** | `string \| Record` | optional | Human-readable label shown in metadata lists. | | **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | | **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }` | optional | | -| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }` | optional | | +| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard'>; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }` | optional | | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | -| **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | +| **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -1895,7 +1896,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional (default: `"simple"`) | | +| **type** | `Enum<'simple' \| 'tabbed' \| 'wizard'>` | optional (default: `"simple"`) | Form layout: 'simple' (stacked sections, the default), 'tabbed' (one tab per section) or 'wizard' (one gated step per section). Where the form opens is the caller's choice, not the form's | | **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `integer` | optional | Number of columns for the form body | | **title** | `string` | optional | Form title | @@ -1905,8 +1906,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | | **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | | **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | -| **splitSize** | `number` | optional | Primary split panel size, % (split forms) | -| **splitResizable** | `boolean` | optional | Whether the split is resizable (split forms) | +| **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer forms) | | **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | @@ -1974,7 +1975,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional (default: `"simple"`) | | +| **type** | `Enum<'simple' \| 'tabbed' \| 'wizard'>` | optional (default: `"simple"`) | Form layout: 'simple' (stacked sections, the default), 'tabbed' (one tab per section) or 'wizard' (one gated step per section). Where the form opens is the caller's choice, not the form's | | **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `integer` | optional | Number of columns for the form body | | **title** | `string` | optional | Form title | @@ -1984,8 +1985,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | | **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | | **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | -| **splitSize** | `number` | optional | Primary split panel size, % (split forms) | -| **splitResizable** | `boolean` | optional | Whether the split is resizable (split forms) | +| **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer forms) | | **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | @@ -2399,15 +2400,30 @@ Installed package with runtime lifecycle state | **readCount** | `number` | ✅ | Number of notifications marked as read | +--- + +## MetadataAuthoringClient + +The OAuth client an MCP build-door write was made by: the client a person consented to with `metadata:write`, as the MCP build door verified it from the request's token. Closed: a member other than `id` and `name` is refused, never stripped. + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | The OAuth client id the verified token was issued to. Opaque — compare it, never parse it. | +| **name** | `string` | optional | The client's registered display name (its OAuth `client_name`) when it registered one, as registered. Absent when it registered none; ⛔ never the id standing in for it. | + + --- ## MetadataAuthoringSurface -The authoring surface of a metadata write. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write and a Studio write of the same user carry the same `actor`. +The authoring surface of a metadata write. Closed: `copilot` is an AI assistant's tool writing on a person's behalf (Studio's AI copilot); `mcp` is an AI agent outside the console writing on a person's behalf through the MCP build door, as the OAuth client the person consented to; `studio` is a person editing in Studio; `api` is any other caller of the save door. It names the surface, not the person: a copilot write, an MCP write and a Studio write of the same user carry the same `actor`. ### Allowed Values * `copilot` +* `mcp` * `studio` * `api` @@ -2788,7 +2804,15 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | **mode** | `Enum<'draft' \| 'publish'>` | optional | Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a pending draft overlay (`?mode=draft` on the REST door; the publish door promotes it later); `publish` or ABSENT writes straight to the live `active` row — the legacy default, kept so callers that predate the draft/publish split keep working. Any value other than `draft` is read as `publish`. | | **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=` on the REST door, where `all` and an empty value name no package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent or `null` names no package. An `active` save then writes the row of the one package the item's active rows are bound to, as a save naming that package does, is refused `409 RESOURCE_CONFLICT` when they are bound to two or more, and with none writes the env-local overlay (no package stamp); a `draft` save writes the draft of the one package the item's active rows are bound to (a pending change stays in the package it edits), else its package-unbound draft, adopted into that package — the draft the per-item publish naming no package promotes. For an item never published into a package it is the package-unbound draft, else the one package's draft, and with no draft at all the save creates the package-unbound draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages, as that publish is (see `PublishMetaItemRequestSchema.packageId`). The row written is also the one the parent-version resolution reads: an unpinned save takes its version as the parent (last-write-wins), a pinned one is judged against it, and the item read at the same scope serves it as `version`. | | **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch' \| 'external-import'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher, the duplicate-package door and the external-table import have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. | -| **surface** | `Enum<'copilot' \| 'studio' \| 'api'>` | optional | The authoring surface this save is made through (see `MetadataAuthoringSurfaceSchema`). A draft save (`mode: 'draft'`) stamps it as `by` on every key path it changes, the entries the strict draft read serves as `draftChanges`; an active save records no per-key provenance. ABSENT stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` and refuses every other value `400 VALIDATION_ERROR`, `copilot` included: `copilot` is stated only by an in-process AI tool calling the save, never by a remote caller, so an HTTP request cannot mark its own write as the copilot's. A value outside the set in-process is refused `400 INVALID_REQUEST`, before anything is written. | +| **surface** | `Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>` | optional | The authoring surface this save is made through (see `MetadataAuthoringSurfaceSchema`). A draft save (`mode: 'draft'`) stamps it as `by` on every key path it changes, the entries the strict draft read serves as `draftChanges`; an active save records no per-key provenance. ABSENT stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` and refuses every other value `400 VALIDATION_ERROR`, `copilot` and `mcp` included: `copilot` is stated only by an in-process AI tool calling the save and `mcp` only by the MCP build door after it verified the client's token, never by a remote caller, so an HTTP request cannot mark its own write as the copilot's or an MCP agent's. A value outside the set in-process is refused `400 INVALID_REQUEST`, before anything is written. | +| **client** | `{ id: string; name?: string }` | optional | The OAuth client this save is made by (see `MetadataAuthoringClientSchema`), stated only by the MCP build door, in-process, from the token it verified. Only with `surface: 'mcp'`: a `client` beside any other surface, or a malformed one, is refused `400 INVALID_REQUEST` before anything is written. A draft save stamps it as `client` beside `by: 'mcp'` on every key path it changes; an active save records it nowhere. No HTTP door reads it: the REST door and the dispatcher build this request member by member, so a `client` in a wire body or query is never read. | + +### Nested Shape: `SaveMetaItemRequest.client` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | The OAuth client id the verified token was issued to. Opaque — compare it, never parse it. | +| **name** | `string` | optional | The client's registered display name (its OAuth `client_name`) when it registered one, as registered. Absent when it registered none; ⛔ never the id standing in for it. | --- From c483a00ecc316adc468f61952a558554e84c8efb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:57:13 +0000 Subject: [PATCH 8/8] test(runtime): the dispatcher's POST body `fields: []` is refused at 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 Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT --- .../src/domains/data-empty-projection-count.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/domains/data-empty-projection-count.test.ts b/packages/runtime/src/domains/data-empty-projection-count.test.ts index 330c2577c5b..d4e274341bf 100644 --- a/packages/runtime/src/domains/data-empty-projection-count.test.ts +++ b/packages/runtime/src/domains/data-empty-projection-count.test.ts @@ -112,7 +112,6 @@ describe('[#26644] the dispatcher /data doors — an empty projection and an unr ['GET list ?$select=, (the card)', 'GET', '/data/inquiry', { $select: ',' }, undefined, '$select', 'names no field'], ['GET list ?select= (the card)', 'GET', '/data/inquiry', { select: '' }, undefined, 'select', 'names no field'], ['GET list ?$count=maybe (the card)', 'GET', '/data/inquiry', { $top: '2', $count: 'maybe' }, undefined, '$count', 'must be true or false'], - ['POST query {"fields":[]}', 'POST', '/data/inquiry/query', {}, { fields: [] }, 'fields', 'names no field'], ['GET by id ?select=', 'GET', '/data/inquiry/r1', { select: '' }, undefined, 'select', 'names no field'], ['GET by id ?select=,', 'GET', '/data/inquiry/r1', { select: ',' }, undefined, 'select', 'names no field'], ])('%s: 400 VALIDATION_ERROR naming the spelling — no read', async (_name, method, path, query, body, param, sentence) => { @@ -131,12 +130,18 @@ describe('[#26644] the dispatcher /data doors — an empty projection and an unr // naming no field is refused where every other malformed body is: at the // schema, `400 VALIDATION_FAILED` naming `query.`, and the member for // a blank name (the #25269 precedent for the page window). Still a 400, - // still before any read. + // still before any read. [#27166] The canonical `fields` (top level and + // on an `expand` entry) is the QueryAST's own projection, which now takes + // the same array arm, so a body naming no field there is refused at the + // schema too, as its `$select` alias is. it.each<[string, Record, string]>([ ['{"select":""}', { select: '' }, 'query.select'], ['{"$select":","}', { $select: ',' }, 'query.$select'], ['{"select":[]}', { select: [] }, 'query.select'], ['{"select":["name",""]} (the blank member)', { select: ['name', ''] }, 'query.select.1'], + ['{"fields":[]}', { fields: [] }, 'query.fields'], + ['{"fields":["name",""]} (the blank member)', { fields: ['name', ''] }, 'query.fields.1'], + ['{"expand":{"owner":{"fields":[]}}}', { expand: { owner: { object: 'sys_user', fields: [] } } }, 'query.expand.owner.fields'], ])('POST query %s: 400 VALIDATION_FAILED naming the body key at the schema — no read', async (_name, body, field) => { const before = reads.n;