diff --git a/.changeset/22386-spec-object-attached-on-read.md b/.changeset/22386-spec-object-attached-on-read.md new file mode 100644 index 00000000000..3c65c049b18 --- /dev/null +++ b/.changeset/22386-spec-object-attached-on-read.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": minor +--- + +`ObjectSchema.attachedOnRead`: an object declares the blocks a service attaches to each row it serves, computed per caller on read and never stored + +Clause-②: yes (widening: a new optional `ObjectSchema` key) + +- **The key.** `attachedOnRead` is an optional map from block name to that block's leaves, each leaf naming its value type: `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. Block names and leaf keys take the field-name grammar (lowercase snake_case). A leaf's type is one of `number`, `text`, `boolean` or `date`, the four value types `Field.returnType` declares. +- **It is not a field.** It provisions no column, and no driver, form, list view, export, write path or translation bundle reads it. Its reader is the shared build validator, `@objectstack/lint`'s expression rule over `@objectstack/formula` (what `os build` and `os validate` run): `record.` resolves, and `record..` resolves only to a leaf the block declares (see the `@objectstack/formula` and `@objectstack/lint` entries). No other field-existence check reads it. +- **Refused at parse:** a leaf type outside the four, a leaf that is a nested block or a field definition, a block or leaf name outside the grammar, a block that names no leaf, and a block that repeats a field name the object declares. Each refusal is located at the offending key. +- **Nothing to migrate.** The key is optional and nothing writes it by default; an object without it parses and validates exactly as before. diff --git a/.changeset/22386-validator-attached-on-read-leaf.md b/.changeset/22386-validator-attached-on-read-leaf.md new file mode 100644 index 00000000000..9e8ff7dc20a --- /dev/null +++ b/.changeset/22386-validator-attached-on-read-leaf.md @@ -0,0 +1,12 @@ +--- +"@objectstack/formula": minor +"@objectstack/lint": patch +"@objectstack/metadata-core": patch +--- + +The shared build validator (`@objectstack/lint`'s expression rule over `@objectstack/formula`) judges `record..` against an object's declared read attachments (`ObjectSchema.attachedOnRead`) + +- **`@objectstack/lint`.** The field index the expression rule builds now adds each block an object declares under `attachedOnRead` to the names `record.` resolves to, and hands the shared validator the block's declared leaves. A predicate such as `record.viewer.can_act` on an object that declares the `viewer` block is accepted where it was refused as an unknown field. +- **`@objectstack/formula`.** `ExprSchemaHint` gains an optional `attachedOnRead` (block name → declared leaf keys). When a `record.` or `previous.` reference names a declared block, its next segment must be a leaf the block declares. Anything else is refused with the existing `unknown-field` code: `field` is the dotted path as written (`viewer.can_actt`), `suggestion` the nearest declared leaf (`viewer.can_act`), and the new optional `block` and `leaves` params carry the leaves the block declares, which the message names. No new refusal code. +- **`@objectstack/metadata-core`.** The ADR-0106 field-level-security masker classifies the new top-level key as passed through unchanged (`OBJECT_REFERENCE_POSITIONS.attachedOnRead`): a block name and its leaf keys never name a field, so no caller's served object definition loses or gains anything. +- **Unchanged.** An object that declares no block keeps every verdict it had, and a second segment off any other field is not judged. Index access, a method call on a block and a third segment stay unjudged. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index a8e855d1901..dfba6ebac66 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -948,6 +948,7 @@ Metadata query with filtering, sorting, and pagination | **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. | | **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record; … }` | optional | Remote table binding for federated (external) objects. | | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. | +| **attachedOnRead** | `Record>>` | optional | Blocks a service attaches to each row it serves, computed per caller on read and never stored: block name → `{ leaf key → value type (number \| text \| boolean \| date) }`. NOT a field — no column, form, list view, export, write path or translation bundle reads it, and a block name may not repeat a declared field name. Its reader is the shared build validator (`@objectstack/lint` over `@objectstack/formula`, as `os build` / `os validate` run it): `record.` resolves, and `record..` resolves only to a leaf the block declares. | | **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | | **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index aa3ff23f327..5ee0bcffd7d 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -152,6 +152,7 @@ const result = ApiMethod.parse(data); | **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. | | **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record; … }` | optional | Remote table binding for federated (external) objects. | | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. | +| **attachedOnRead** | `Record>>` | optional | Blocks a service attaches to each row it serves, computed per caller on read and never stored: block name → `{ leaf key → value type (number \| text \| boolean \| date) }`. NOT a field — no column, form, list view, export, write path or translation bundle reads it, and a block name may not repeat a declared field name. Its reader is the shared build validator (`@objectstack/lint` over `@objectstack/formula`, as `os build` / `os validate` run it): `record.` resolves, and `record..` resolves only to a leaf the block declares. | | **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | | **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 1353735f25c..f9e83c7062d 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -329,6 +329,7 @@ Create a new object | **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. | | **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record; … }` | optional | Remote table binding for federated (external) objects. | | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. | +| **attachedOnRead** | `Record>>` | optional | Blocks a service attaches to each row it serves, computed per caller on read and never stored: block name → `{ leaf key → value type (number \| text \| boolean \| date) }`. NOT a field — no column, form, list view, export, write path or translation bundle reads it, and a block name may not repeat a declared field name. Its reader is the shared build validator (`@objectstack/lint` over `@objectstack/formula`, as `os build` / `os validate` run it): `record.` resolves, and `record..` resolves only to a leaf the block declares. | | **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | | **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | @@ -616,6 +617,7 @@ Create a new object | **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. | | **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record; … }` | optional | Remote table binding for federated (external) objects. | | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. | +| **attachedOnRead** | `Record>>` | optional | Blocks a service attaches to each row it serves, computed per caller on read and never stored: block name → `{ leaf key → value type (number \| text \| boolean \| date) }`. NOT a field — no column, form, list view, export, write path or translation bundle reads it, and a block name may not repeat a declared field name. Its reader is the shared build validator (`@objectstack/lint` over `@objectstack/formula`, as `os build` / `os validate` run it): `record.` resolves, and `record..` resolves only to a leaf the block declares. | | **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | | **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | diff --git a/packages/formula/src/expression-refusal-codes.test.ts b/packages/formula/src/expression-refusal-codes.test.ts index 45f230a58e8..c49f3580104 100644 --- a/packages/formula/src/expression-refusal-codes.test.ts +++ b/packages/formula/src/expression-refusal-codes.test.ts @@ -235,6 +235,26 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin, ...Pin params: { field: 'zzz' }, message: 'unknown field `zzz`', }, + { + // A declared read attachment's second segment (#22386): the same code, + // the dotted path, and the optional clause naming the declared leaves. + produce: () => + onlyError('predicate', 'record.viewer.can_actt', { + fields: ['status', 'viewer'], + objectName: 'approval', + attachedOnRead: { viewer: ['can_act', 'is_submitter'] }, + }), + params: { + field: 'viewer.can_actt', + objectName: 'approval', + suggestion: 'viewer.can_act', + block: 'viewer', + leaves: ['can_act', 'is_submitter'], + }, + message: + 'unknown field `viewer.can_actt` on `approval` (the read attachment `viewer` declares `can_act`, `is_submitter`) ' + + '— did you mean `viewer.can_act`?', + }, ], 'unknown-role': [ { diff --git a/packages/formula/src/expression-refusal.ts b/packages/formula/src/expression-refusal.ts index 5b22eb6880e..8f9f210b42a 100644 --- a/packages/formula/src/expression-refusal.ts +++ b/packages/formula/src/expression-refusal.ts @@ -96,8 +96,21 @@ export interface ExpressionRefusalParams { }; /** The CEL engine refused a source that holds a template brace `{ref}`. */ 'cel-template-brace': { readonly role: CelFieldRole; readonly detail: string; readonly ref: string }; - /** `record.` naming no field of the object. */ - 'unknown-field': { readonly field: string; readonly objectName?: string; readonly suggestion?: string }; + /** + * `record.` naming no field of the object — or, for a declared read + * attachment (`ExprSchemaHint.attachedOnRead`), `record..` + * naming no leaf of the block: then `field` is the dotted path as written + * and `suggestion` the nearest declared leaf, also dotted. `block` and + * `leaves` are present together, exactly when the message carries the clause + * naming the leaves the block declares. + */ + 'unknown-field': { + readonly field: string; + readonly objectName?: string; + readonly suggestion?: string; + readonly block?: string; + readonly leaves?: readonly string[]; + }; /** A role-membership test naming a role outside the catalog; `catalog` is every valid role. */ 'unknown-role': { readonly name: string; readonly suggestion?: string; readonly catalog: readonly string[] }; /** `name.…` one typo away from a root the surface binds; `roots` are the surface's declared roots. */ diff --git a/packages/formula/src/validate-attached-on-read.test.ts b/packages/formula/src/validate-attached-on-read.test.ts new file mode 100644 index 00000000000..cabc9c019e4 --- /dev/null +++ b/packages/formula/src/validate-attached-on-read.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `ExprSchemaHint.attachedOnRead` — the second segment of + * `record..` judged against a declared read attachment (#22211 + * ruling A, #22386). + * + * A read attachment is a block a service sets on each row it serves, computed + * per caller and never stored — plugin-approvals' `viewer: { can_act, + * can_override, is_submitter }` on `sys_approval_request` is the worked case. + * The block name resolves like a field (the caller lists it in `fields`); the + * hint only adds the judgement of the segment after it, under the EXISTING + * `unknown-field` code — no new code, and nothing keyed on the name `viewer`. + * + * Each verdict is asserted on its `code` and `params` (the named subject and + * the remedy), the machine-readable half of the refusal. + */ + +import { describe, it, expect } from 'vitest'; + +import { validateExpression, type ExprSchemaHint } from './validate'; + +const LEAVES = ['can_act', 'can_override', 'is_submitter'] as const; + +/** An object that declares the `viewer` read attachment, as the lint field index hands it over. */ +const DECLARED: ExprSchemaHint = { + objectName: 'sys_approval_request', + fields: ['status', 'submitter_id', 'viewer'], + attachedOnRead: { viewer: LEAVES }, + scope: 'record', +}; + +/** The same object WITHOUT the key — today's hint, byte for byte. */ +const UNDECLARED: ExprSchemaHint = { + objectName: 'sys_approval_request', + fields: ['status', 'submitter_id'], + scope: 'record', +}; + +function refusalsOf(source: string, schema: ExprSchemaHint) { + return validateExpression('predicate', source, schema).errors.map((e) => ({ code: e.code, params: e.params })); +} + +describe('ExprSchemaHint.attachedOnRead — record..', () => { + it('accepts a leaf the block declares', () => { + for (const leaf of LEAVES) { + const r = validateExpression('predicate', `record.viewer.${leaf} == true`, DECLARED); + expect(r.errors, leaf).toEqual([]); + expect(r.ok).toBe(true); + } + // The shipped shape: an OR of two leaves and a status read, one predicate. + expect( + validateExpression('predicate', "record.status == 'pending' && (record.viewer.can_act || record.viewer.can_override)", DECLARED).errors, + ).toEqual([]); + }); + + it('refuses a misspelt leaf under the existing `unknown-field` code, naming the declared leaves', () => { + expect(refusalsOf('record.viewer.can_actt == true', DECLARED)).toEqual([ + { + code: 'unknown-field', + params: { + field: 'viewer.can_actt', + objectName: 'sys_approval_request', + suggestion: 'viewer.can_act', + block: 'viewer', + leaves: ['can_act', 'can_override', 'is_submitter'], + }, + }, + ]); + }); + + it('refuses a leaf with no near declared spelling, still naming every declared leaf', () => { + expect(refusalsOf('record.viewer.approved', DECLARED)).toEqual([ + { + code: 'unknown-field', + params: { + field: 'viewer.approved', + objectName: 'sys_approval_request', + block: 'viewer', + leaves: ['can_act', 'can_override', 'is_submitter'], + }, + }, + ]); + }); + + it('judges the `previous` root the same way, and reports one leaf once', () => { + expect(refusalsOf('previous.viewer.can_actt || record.viewer.can_actt', DECLARED)).toEqual([ + { + code: 'unknown-field', + params: { + field: 'viewer.can_actt', + objectName: 'sys_approval_request', + suggestion: 'viewer.can_act', + block: 'viewer', + leaves: ['can_act', 'can_override', 'is_submitter'], + }, + }, + ]); + }); + + it('refuses an undeclared block exactly as today — the FIRST segment, not the second', () => { + expect(refusalsOf('record.viewr.can_act == true', DECLARED)).toEqual([ + { code: 'unknown-field', params: { field: 'viewr', objectName: 'sys_approval_request', suggestion: 'viewer' } }, + ]); + }); + + it('an object without the key keeps today’s verdicts (control)', () => { + // The block is unknown as a FIELD, exactly as before the key existed … + expect(refusalsOf('record.viewer.can_act == true', UNDECLARED)).toEqual([ + { code: 'unknown-field', params: { field: 'viewer', objectName: 'sys_approval_request' } }, + ]); + // … and a second segment off a known field is not judged at all. + expect(refusalsOf('record.submitter_id.name == "x"', UNDECLARED)).toEqual([]); + // Same expression, same verdict, with or without an EMPTY declaration. + expect(refusalsOf('record.submitter_id.name == "x"', { ...UNDECLARED, attachedOnRead: {} })).toEqual([]); + }); + + it('judges only a declared block: a second segment off any other field stays unjudged', () => { + expect(refusalsOf('record.submitter_id.nmae == "x"', DECLARED)).toEqual([]); + }); + + it('leaves a method call, an index read and a third segment unjudged — missed catches, never false refusals', () => { + expect(refusalsOf("record.viewer['can_actt'] == true", DECLARED)).toEqual([]); + expect(refusalsOf("record.viewer.split(',') == []", DECLARED)).toEqual([]); + expect(refusalsOf('record.viewer.can_act.foo == true', DECLARED)).toEqual([]); + }); + + it('reads the declaration by OWN key only — an inherited name is never a block', () => { + const hint: ExprSchemaHint = { ...DECLARED, fields: [...(DECLARED.fields ?? []), 'constructor'] }; + expect(refusalsOf('record.constructor.name == "x"', hint)).toEqual([]); + }); +}); diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index a789392bbba..f7e24675422 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -65,6 +65,21 @@ export interface ExprSchemaHint { objectName?: string; /** Known top-level field names, so `record.` can be checked. */ fields?: readonly string[]; + /** + * The object's read attachments (`ObjectSchema.attachedOnRead`) — block name + * → the leaf keys that block declares. A block is computed per caller and + * attached to each served row, never stored, so it is not a field; this map + * lets the field-existence pass judge the SECOND segment of + * `record..` (and `previous.…`): a leaf the block does not + * declare is refused under the same `unknown-field` code, and the refusal + * names the leaves the block does declare. + * + * It only ADDS the second-segment judgement. Whether `record.` + * resolves at all is still {@link fields}' question, so a caller lists each + * block name there too (`@objectstack/lint`'s field index does). Absent or + * empty ⇒ only first segments are judged, exactly as before this key. + */ + attachedOnRead?: Readonly>; /** * #1928 tier 4 — field name → spec field type (`'text'`, `'currency'`, * `'boolean'`, `'date'`, …). Enables the advisory type-soundness check: a @@ -310,6 +325,14 @@ function typeSoundnessIssue( const SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/; /** `record.` / `previous.` head references for field-existence. */ const RECORD_REF_RE = /\b(?:record|previous)\.([A-Za-z_$][\w$]*)/g; +/** + * The member read right after a {@link RECORD_REF_RE} head — `.can_act` in + * `record.viewer.can_act` — matched STICKY at the head's end, so the head scan + * itself is untouched. A member followed by `(` is a method call, not a member + * read, and is not captured; the trailing `(?![\w$])` stops a backtrack from + * capturing a prefix of the name instead. + */ +const SECOND_SEGMENT_RE = /\.([A-Za-z_$][\w$]*)(?![\w$]|\s*\()/y; /** The dialect a field role expects (Decision 2). */ export function expectedDialect(role: FieldRole): 'cel' | 'template' { @@ -626,11 +649,22 @@ function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, if (!schema?.fields || schema.fields.length === 0) return; const known = new Set(schema.fields); const seen = new Set(); + const blocks = schema.attachedOnRead; + const seenLeaves = new Set(); let m: RegExpExecArray | null; RECORD_REF_RE.lastIndex = 0; while ((m = RECORD_REF_RE.exec(source)) !== null) { const field = m[1]; - if (seen.has(field) || known.has(field)) continue; + if (known.has(field)) { + // [#22211 ruling A] A head that names a declared read attachment has a + // closed second segment: judge it against the block's declared leaves. + // Own-key test, so an inherited name (`constructor`) is never a block. + if (blocks && Object.prototype.hasOwnProperty.call(blocks, field)) { + checkAttachedLeaf(source, schema.objectName, field, blocks[field], RECORD_REF_RE.lastIndex, seenLeaves, errors); + } + continue; + } + if (seen.has(field)) continue; seen.add(field); const suggestion = nearest(field, schema.fields); errors.push(refusal( @@ -647,6 +681,59 @@ function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, } } +/** + * [#22211 ruling A] The second segment of `record..` when + * `` is a declared read attachment (`ExprSchemaHint.attachedOnRead`). + * + * The same `unknown-field` refusal as a first segment, with `field` naming the + * dotted path as written, so a consumer rendering only the first-segment params + * still says something true. The one addition is an optional clause naming the + * leaves the block declares — the remedy — carried as `block` + `leaves`, + * present together exactly when the message carries that clause. + * + * Unjudged, deliberately (each a missed catch, never a false refusal): index + * access (`record.viewer['can_act']`), a method call on the block, and any + * segment past the second — a leaf is a scalar, so a third segment is not this + * declaration's question. + */ +function checkAttachedLeaf( + source: string, + objectName: string | undefined, + block: string, + leaves: unknown, + headEnd: number, + seenLeaves: Set, + errors: ExprValidationError[], +): void { + if (!Array.isArray(leaves)) return; + SECOND_SEGMENT_RE.lastIndex = headEnd; + const next = SECOND_SEGMENT_RE.exec(source); + if (!next) return; + const leaf = next[1]; + if (leaves.includes(leaf)) return; + const field = `${block}.${leaf}`; + if (seenLeaves.has(field)) return; + seenLeaves.add(field); + const declared = leaves.filter((l): l is string => typeof l === 'string'); + const nearLeaf = nearest(leaf, declared); + const suggestion = nearLeaf ? `${block}.${nearLeaf}` : undefined; + errors.push(refusal( + 'unknown-field', + { + field, + ...(objectName ? { objectName } : {}), + ...(suggestion ? { suggestion } : {}), + ...(declared.length > 0 ? { block, leaves: declared } : {}), + }, + source, + `unknown field \`${field}\`${objectName ? ` on \`${objectName}\`` : ''}` + + (declared.length > 0 + ? ` (the read attachment \`${block}\` declares ${declared.map((l) => `\`${l}\``).join(', ')})` + : '') + + (suggestion ? ` — did you mean \`${suggestion}\`?` : ''), + )); +} + /** * Is `name` written as a NAMESPACE in `source` — `name.x`, `name?.x`, * `name['x']`, `name.fn(…)` — rather than as a bare value (`name == 'x'`)? diff --git a/packages/lint/src/validate-expressions.attached-on-read.test.ts b/packages/lint/src/validate-expressions.attached-on-read.test.ts new file mode 100644 index 00000000000..1921a07774c --- /dev/null +++ b/packages/lint/src/validate-expressions.attached-on-read.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22211 ruling A, #22386] `buildFieldIndex` and the shared validator read + * `ObjectSchema.attachedOnRead`. + * + * A read attachment is a block a service sets on each row it serves, computed + * per caller and never stored. Declared, its block name joins the names + * `record.` resolves to, and the second segment of `record..` + * is judged against the block's declared leaves — under the EXISTING + * `unknown-field` refusal, with no new rule and nothing keyed on a block's + * name. Undeclared, every verdict is what it was. + * + * Every fixture goes through `ObjectStackSchema.safeParse` first: this rule is + * registered `input: 'parsed'`, so a fixture the spec refuses would pin a + * branch no author can reach (the #5017 discipline). + * + * The probe object mirrors the shipped shape the ruling names — an action + * `visible` predicate over `record.viewer.can_act` — without being the shipped + * object: declaring `sys_approval_request`'s block is #22387's. + */ + +import { describe, it, expect } from 'vitest'; + +import { ObjectStackSchema } from '@objectstack/spec'; + +import { validateStackExpressions } from './validate-expressions.js'; + +const MANIFEST = { id: 'com.example.attached-on-read', name: 'attached_on_read_probe', version: '1.0.0', type: 'app' } as const; + +/** A fixture is only a fixture if the spec accepts it. */ +function specValid(stack: Record): Record { + const result = ObjectStackSchema.safeParse({ manifest: MANIFEST, ...stack }); + if (!result.success) { + throw new Error( + 'fixture is not spec-valid: ' + + result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '), + ); + } + return result.data as unknown as Record; +} + +const VIEWER = { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } as const; + +/** The probe object, with or without the declaration. */ +const probe = (declared: boolean) => ({ + name: 'approval_probe', + label: 'Approval probe', + fields: { + status: { type: 'text', label: 'Status' }, + amount: { type: 'number', label: 'Amount' }, + }, + ...(declared ? { attachedOnRead: { viewer: VIEWER } } : {}), +}); + +/** One action whose `visible` predicate is the expression under test. */ +function issuesFor(visible: string, declared: boolean) { + return validateStackExpressions( + specValid({ + objects: [probe(declared)], + actions: [{ name: 'approve_probe', label: 'Approve', objectName: 'approval_probe', type: 'script', target: 'fn', visible }], + }), + ); +} + +describe('attachedOnRead — record.. through the stack rule', () => { + it('accepts a declared leaf', () => { + expect(issuesFor("record.status == 'pending' && (record.viewer.can_act || record.viewer.can_override)", true)).toEqual([]); + expect(issuesFor('record.viewer.is_submitter', true)).toEqual([]); + }); + + it('refuses a misspelt leaf, naming the leaves the block declares', () => { + const issues = issuesFor('record.viewer.can_actt', true); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe('error'); + expect(issues[0].where).toContain("action 'approve_probe'"); + // The named subject, the declared leaves as the remedy, and the nearest one. + expect(issues[0].message).toContain('unknown field `viewer.can_actt` on `approval_probe`'); + expect(issues[0].message).toContain('`can_act`, `can_override`, `is_submitter`'); + expect(issues[0].message).toContain('did you mean `viewer.can_act`?'); + }); + + it('refuses an undeclared block as today — the first segment', () => { + const issues = issuesFor('record.viewr.can_act', true); + expect(issues).toHaveLength(1); + expect(issues[0].message).toMatch(/^unknown field `viewr` on `approval_probe` — did you mean `viewer`\?$/); + }); + + it('an object without the key keeps today’s verdict (control)', () => { + const issues = issuesFor('record.viewer.can_act', false); + expect(issues).toHaveLength(1); + expect(issues[0].message).toBe('unknown field `viewer` on `approval_probe`'); + // And a declared field's second segment stays unjudged with the key absent. + expect(issuesFor('record.amount > 0', false)).toEqual([]); + }); + + it('reaches the field-formula pass too: the same index serves both call sites that pass the field index', () => { + const formula = (expression: string) => + validateStackExpressions( + specValid({ + objects: [{ + ...probe(true), + fields: { + ...probe(true).fields, + flagged: { type: 'formula', label: 'Flagged', expression }, + }, + }], + }), + ); + // Only the refusal half is pinned here: whether a block a SERVED row carries + // belongs in a stored formula at all is not this rule's question (the + // ruling places it in the field-existence set, which every surface reads). + const issues = formula('record.viewer.can_actt ? 1 : 0'); + expect(issues).toHaveLength(1); + expect(issues[0].where).toBe("object 'approval_probe' · field 'flagged' expression"); + expect(issues[0].message).toContain('unknown field `viewer.can_actt`'); + }); +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 57d71fe9abe..54faad26e0c 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -3264,7 +3264,9 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s { receiver: 'obj', // `validationRules` is absent — one of the five #5017 removed. - expected: ['actions', 'fields', 'name', 'validations'], + // `attachedOnRead` joined with #22386 (#22211 ruling A): the declared read + // attachments, whose block names join the field index. + expected: ['actions', 'attachedOnRead', 'fields', 'name', 'validations'], declaredBy: 'ObjectSchema', keys: () => Object.keys(ObjectSchema.shape), }, @@ -3478,6 +3480,10 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t // Every one is a `string[]` of member NAMES whose keys are Array methods, // never metadata keys — each named so no metadata receiver hides behind it. 'declaredUserMembers', 'boundUserMembers', 'listedNames', 'tickedNames', 'membersRead', + // [#22386] The per-object read-attachment index (block name → leaf keys). + // Its one "key" is `Map.prototype.get`; the metadata key it is built + // from is read off the tabled `obj` receiver (`obj.attachedOnRead`). + 'attachedOnReadIndex', ]); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 0181997b0f5..3edaa4b67ac 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -34,6 +34,7 @@ * | Read | Declared by | * |----------------------------------------|-----------------------------------| * | `objects[].validations[]` | `ObjectSchema` | + * | `objects[].attachedOnRead` | `ObjectSchema` | * | `validations[].condition` / `.when` / `.then` / `.otherwise` | the six `*ValidationSchema` variants | * | `objects[].fields[].reference` | `FieldSchema` | * | `objects[].fields[].expression` | `FieldSchema` | @@ -179,7 +180,57 @@ function buildFieldIndex(objects: AnyRec[]): Map { // Injected columns come second, de-duplicated by insertion order: a DECLARED // `owner_id` is the author's field (the registry lets it win), so the // authored spelling keeps its position in the "did you mean?" candidates. - idx.set(name, [...new Set([...names, ...injectedColumnsFor(obj)])]); + // [#22211 ruling A] Declared read attachments come last: a block the + // object's service attaches per caller on read is something `record.` + // resolves to on a served row, although it is not a field. Its second + // segment is judged against the block's own leaves — see + // {@link buildAttachedOnReadIndex}. + idx.set(name, [...new Set([...names, ...injectedColumnsFor(obj), ...attachedBlockNames(obj)])]); + } + return idx; +} + +/** + * [#22211 ruling A] The object's declared read-attachment blocks — + * `ObjectSchema.attachedOnRead`, block name → (leaf key → value type) — as + * block name → its leaf keys, in declaration order. Empty for an object that + * declares none, which is every object that does not write the key: such an + * object hands the shared validator no `attachedOnRead` and keeps exactly the + * verdicts it had. + * + * Own keys only, read off the parsed value; the spec's strict record has + * already refused a malformed block or leaf at parse. + */ +function attachedOnReadOf(obj: AnyRec): Record { + const declared = obj.attachedOnRead; + const out: Record = {}; + if (!declared || typeof declared !== 'object' || Array.isArray(declared)) return out; + for (const [block, leaves] of Object.entries(declared as AnyRec)) { + if (!leaves || typeof leaves !== 'object' || Array.isArray(leaves)) continue; + out[block] = Object.keys(leaves as AnyRec); + } + return out; +} + +/** The block names {@link attachedOnReadOf} reads — what joins the field-existence set. */ +function attachedBlockNames(obj: AnyRec): string[] { + return Object.keys(attachedOnReadOf(obj)); +} + +/** + * [#22211 ruling A] object name → its declared read attachments (block name → + * leaf keys), for the shared validator's second-segment judgement of + * `record..` (`ExprSchemaHint.attachedOnRead`). Only objects that + * declare at least one block get an entry, so `index.get(name)` is `undefined` + * — the hint absent — for every other object. + */ +function buildAttachedOnReadIndex(objects: AnyRec[]): Map> { + const idx = new Map>(); + for (const obj of objects) { + const name = typeof obj.name === 'string' ? obj.name : undefined; + if (!name) continue; + const blocks = attachedOnReadOf(obj); + if (Object.keys(blocks).length > 0) idx.set(name, blocks); } return idx; } @@ -1652,6 +1703,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression const objectWrite = options.runtimeWriteType === 'object'; const objects = recordsOf(stack.objects); const fieldIndex = buildFieldIndex(objects); + const attachedOnReadIndex = buildAttachedOnReadIndex(objects); const fieldTypeIndex = buildFieldTypeIndex(objects); const nullableIndex = buildNullableFieldIndex(objects); @@ -1803,8 +1855,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // Field types feed the #1928 tier-4 soundness warning; only consulted for // `record`-scoped sites, so it is harmless to pass for flattened ones too. const fieldTypes = objectName ? fieldTypeIndex.get(objectName) : undefined; + // [#22211 ruling A] Absent for an object that declares no read attachment. + const attachedOnRead = objectName ? attachedOnReadIndex.get(objectName) : undefined; const res = validateExpression('predicate', raw as string | { dialect?: string; source?: string }, - objectName ? { objectName, fields, fieldTypes, scope, traversalHydration } : { scope }); + objectName ? { objectName, fields, attachedOnRead, fieldTypes, scope, traversalHydration } : { scope }); for (const e of res.errors) { if (fieldRuleVerdictIssued && isBareReferenceToAny(e.message, FIELD_RULE_NOWHERE_BOUND_ROOTS)) continue; issues.push({ where, message: e.message, source: e.source, severity: 'error' }); @@ -2426,7 +2480,15 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // deciding later is low. Raise it as its own issue rather than widening // this call. Ledger: `validate-null-guards.ts`. const res = validateExpression('value', f.expression as string | { dialect?: string; source?: string }, - objectName ? { objectName, fields: fieldIndex.get(objectName), fieldTypes: fieldTypeIndex.get(objectName), scope: 'record' } : { scope: 'record' }); + objectName + ? { + objectName, + fields: fieldIndex.get(objectName), + attachedOnRead: attachedOnReadIndex.get(objectName), + fieldTypes: fieldTypeIndex.get(objectName), + scope: 'record', + } + : { scope: 'record' }); // Names the KEY the author edits, not the field type. Saying "formula" // here is how the wrong spelling propagates: the next author reads the // diagnostic and writes `formula:`, which the schema then rejects. diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index d7455eadbd5..77ccfd4f1ad 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -692,6 +692,10 @@ export const OBJECT_REFERENCE_POSITIONS: Readonly> = { managedBy: keep, ownership: keep, systemFields: keep, datasource: keep, access: keep, requiredPermissions: keep, fileAccessDelegate: keep, editMode: keep, enable: keep, sharingModel: keep, externalSharingModel: keep, protection: keep, + // [#22386] Read attachments: a block name and its leaf keys name what a + // service attaches to a served row, never a field — the spec refuses a + // block that repeats a declared field name — so nothing here can be denied. + attachedOnRead: keep, // Role pointers. nameField: pointer, displayNameField: pointer, diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 06490633555..3e9771b0de5 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -305,11 +305,13 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * (the shared-option-list reference), a declared key, not a derivation change; * `object` moved 43 → 44 the same way when it gained `imageField` (the * record's picture, beside `nameField`). `page` moved 24 → 25 the same way - * when it gained `print` (#22158, the print-page declaration). + * when it gained `print` (#22158, the print-page declaration). `object` + * moved 44 → 45 the same way when it gained `attachedOnRead` (#22386, the + * blocks a service attaches per caller on read). */ const CARD_PROPERTY_COUNTS: Record = { agent: 26, app: 30, dashboard: 21, dataset: 16, field: 75, flow: 23, - hook: 22, object: 44, page: 25, position: 12, report: 21, skill: 17, tool: 14, + hook: 22, object: 45, page: 25, position: 12, report: 21, skill: 17, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 9de9b65d328..176becb3f82 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -650,6 +650,7 @@ "data/Object:access", "data/Object:actions", "data/Object:activityMilestones", + "data/Object:attachedOnRead", "data/Object:datasource", "data/Object:description", "data/Object:displayNameField", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index b71fab4782a..11c257516b2 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -70,6 +70,14 @@ "evidence": "packages/objectql/src/engine.ts", "note": "engine/DDL + forms." }, + "attachedOnRead": { + "status": "live", + "verifiedAt": "2026-10-09", + "evidenceScope": "in-repo", + "evidence": "packages/lint/src/validate-expressions.ts#attachedOnReadOf (the read of `obj.attachedOnRead`: block name → its leaf keys); packages/lint/src/validate-expressions.ts#buildFieldIndex (each declared block name joins the names `record.` resolves to); packages/formula/src/validate.ts#checkAttachedLeaf (the second segment of `record..` judged against the block's declared leaves, under the existing `unknown-field` refusal)", + "producer": "packages/lint/src/validate-expressions.ts#runStackExpressionPasses (threads the per-object index into the shared validator's `attachedOnRead` hint at both call sites that pass the field index: the predicate `check` closure and the field-formula judge)", + "note": "#22211 ruling A (6070963704), built by #22386. `live` on an AUTHORING consumer, the `action.execution` / `dashboard.widgets.suppressWarnings` precedent: the key provisions nothing and changes no dispatch by itself — it is the declaration the shared build validator (`@objectstack/lint` over `@objectstack/formula`) judges `record.` and `record..` against, so `os build` / `os validate` and every door running the shared validator accept a declared leaf and refuse a misspelt one; deleting the key would delete that verdict. Two other field-existence builders read `fields` only and not this key — the MCP expression tool (packages/mcp) and service-automation's flow-registration resolver; their settlement is carried by #22387 (seat note 6075461181). By the ruling NOT a field: no driver, form, list view, export, write path or translation bundle reads it, and the schema refuses a block that repeats a declared field name. The leaf TYPES (the four `Field.returnType` value types) are not read by this consumer, which judges names only: their reader is the ruling's second reader, plugin-approvals' conformance test (#22387, seat order 6074312931), which pins that the keys `attachViewers` emits AND the runtime type of each emitted value equal what `sys_approval_request` declares under `attachedOnRead.viewer` (three `boolean`s). Until that test lands the types are a declaration awaiting its named reader." + }, "datasource": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/liveness/state-counts/object.md b/packages/spec/liveness/state-counts/object.md index 617fe74cbac..2f270ddebb2 100644 --- a/packages/spec/liveness/state-counts/object.md +++ b/packages/spec/liveness/state-counts/object.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `object` | 51 | 0 | 0 | 0 | 1 | 52 | +| `object` | 52 | 0 | 0 | 0 | 1 | 53 | diff --git a/packages/spec/src/compose-stacks-collection-pipe-arm.test.ts b/packages/spec/src/compose-stacks-collection-pipe-arm.test.ts index a46dc9b3538..e845e1acb0b 100644 --- a/packages/spec/src/compose-stacks-collection-pipe-arm.test.ts +++ b/packages/spec/src/compose-stacks-collection-pipe-arm.test.ts @@ -247,6 +247,7 @@ describe('MAIN — the preprocess-wrapped collection key is IN the refusal set', expect(derived).toContain(NAMES.preprocess); // …and the keys it carried before this change are all still there, in order. expect(derived.filter((k) => k !== NAMES.preprocess)).toEqual([ + 'attachedOnRead', 'indexes', 'fieldGroups', 'requiredPermissions', @@ -302,6 +303,7 @@ describe("TODAY-INVARIANCE — the fix moves no key on today's ObjectSchema", () expect(setUnder(shape, authorableWalk), 'the landed rule moved a key on the real shape').toEqual(inOnly); expect(setUnder(shape, eitherSideWalk), '`in || out` would move a key on the real shape').toEqual(inOnly); expect(inOnly).toEqual([ + 'attachedOnRead', 'indexes', 'fieldGroups', 'requiredPermissions', diff --git a/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts b/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts index 3aedf52bfe9..3bea93cf0dd 100644 --- a/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts +++ b/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts @@ -60,6 +60,7 @@ const shared = (out: ObjectStackDefinition) => (out.objects ?? []).find((o) => o * refusal prints it in. `fields` is absent by rule. */ const COLLECTION_KEYS_IN_SHAPE_ORDER = [ + 'attachedOnRead', 'indexes', 'fieldGroups', 'requiredPermissions', @@ -135,6 +136,7 @@ describe("composeStacks objectConflict: 'merge' — a collection both objects de ['highlightFields', ['title'], ['body']], ['activityMilestones', [{ name: 'm_a' }], [{ name: 'm_b' }]], ['requiredPermissions', ['crm.read'], ['billing.read']], + ['attachedOnRead', { viewer: { can_act: 'boolean' } }, { viewer: { can_act: 'boolean', is_submitter: 'boolean' } }], ] as const)("refuses two objects declaring different '%s'", (key, left, right) => { const a = defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', { [key]: left })] }, { strict: false }); const b = defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', { [key]: right })] }, { strict: false }); diff --git a/packages/spec/src/data/object-attached-on-read.test.ts b/packages/spec/src/data/object-attached-on-read.test.ts new file mode 100644 index 00000000000..ff8129f89da --- /dev/null +++ b/packages/spec/src/data/object-attached-on-read.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { ObjectSchema } from './object.zod'; + +// --------------------------------------------------------------------------- +// [#22211 ruling A, #22386] `ObjectSchema.attachedOnRead` — the blocks a +// service attaches to each row it serves, computed per caller and never +// stored: block name → (leaf key → value type). +// +// The declaration is what the shared expression validator judges +// `record..` against (pinned in `@objectstack/formula` and +// `@objectstack/lint`); this file pins the SHAPE: a well-formed declaration +// round-trips, its absence changes nothing, and every malformed form is +// refused at parse, located at the offending key. Refusals are asserted by +// code and path — the machine-readable subject — never by the prose. +// --------------------------------------------------------------------------- + +const VIEWER = { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } as const; + +const request = (attachedOnRead?: unknown, fields: Record = {}) => ({ + name: 'approval_request_probe', + fields: { + status: { type: 'text', label: 'Status' }, + ...fields, + }, + ...(attachedOnRead === undefined ? {} : { attachedOnRead }), +}); + +/** The refusal issues, as `{ code, path }`, of an input that must NOT parse. */ +function refusals(input: unknown): Array<{ code: string; path: PropertyKey[] }> { + const parsed = ObjectSchema.safeParse(input); + expect(parsed.success, 'the declaration must NOT parse clean').toBe(false); + if (parsed.success) return []; + return parsed.error.issues.map((i) => ({ code: i.code, path: i.path })); +} + +describe('ObjectSchema.attachedOnRead — the declaration', () => { + it('accepts a block of leaves typed with the four value types, and keeps it as authored', () => { + const declared = { viewer: VIEWER, decision_progress: { behavior: 'text', got: 'number', need: 'number', due_on: 'date' } }; + const parsed = ObjectSchema.safeParse(request(declared)); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); + if (!parsed.success) return; + expect(parsed.data.attachedOnRead).toEqual(declared); + }); + + it('is optional: an object without it parses exactly as before, with no key materialized (control)', () => { + const parsed = ObjectSchema.safeParse(request()); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect('attachedOnRead' in parsed.data).toBe(false); + }); + + it('is a known key at the authoring factory, so `ObjectSchema.create()` takes it', () => { + const created = ObjectSchema.create({ + name: 'approval_request_probe', + fields: { status: { type: 'text', label: 'Status' } }, + attachedOnRead: { viewer: { ...VIEWER } }, + }); + expect(created.attachedOnRead).toEqual({ viewer: VIEWER }); + }); +}); + +describe('ObjectSchema.attachedOnRead — malformed declarations are refused at parse', () => { + it('refuses a leaf typed outside the four value types', () => { + expect(refusals(request({ viewer: { can_act: 'json' } }))).toEqual([ + { code: 'invalid_value', path: ['attachedOnRead', 'viewer', 'can_act'] }, + ]); + expect(refusals(request({ viewer: { can_act: 'lookup' } }))).toEqual([ + { code: 'invalid_value', path: ['attachedOnRead', 'viewer', 'can_act'] }, + ]); + }); + + it('refuses a leaf that is a nested block or a field definition, not a type', () => { + expect(refusals(request({ viewer: { can: { act: 'boolean' } } }))).toEqual([ + { code: 'invalid_value', path: ['attachedOnRead', 'viewer', 'can'] }, + ]); + expect(refusals(request({ viewer: { can_act: { type: 'boolean' } } }))).toEqual([ + { code: 'invalid_value', path: ['attachedOnRead', 'viewer', 'can_act'] }, + ]); + }); + + it('refuses a leaf key outside the field-name grammar', () => { + const issues = refusals(request({ viewer: { canAct: 'boolean' } })); + expect(issues).toHaveLength(1); + expect(issues[0].path.slice(0, 2)).toEqual(['attachedOnRead', 'viewer']); + expect(issues[0].code).toBe('invalid_key'); + }); + + it('refuses a block that is not a map of leaves', () => { + expect(refusals(request({ viewer: 'boolean' }))).toEqual([ + { code: 'invalid_type', path: ['attachedOnRead', 'viewer'] }, + ]); + expect(refusals(request({ viewer: ['can_act'] }))).toEqual([ + { code: 'invalid_type', path: ['attachedOnRead', 'viewer'] }, + ]); + }); + + it('refuses a block name outside the field-name grammar', () => { + const issues = refusals(request({ Viewer: VIEWER })); + expect(issues).toHaveLength(1); + expect(issues[0].path[0]).toBe('attachedOnRead'); + expect(issues[0].code).toBe('invalid_key'); + }); + + it('refuses a block that names no leaf', () => { + expect(refusals(request({ viewer: {} }))).toEqual([{ code: 'custom', path: ['attachedOnRead', 'viewer'] }]); + }); + + it('refuses a block that reuses a declared field name — a read attachment is not a field', () => { + expect(refusals(request({ viewer: VIEWER }, { viewer: { type: 'text', label: 'Viewer' } }))).toEqual([ + { code: 'custom', path: ['attachedOnRead', 'viewer'] }, + ]); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 20fb460d6f0..6c553b0e2ec 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { FieldSchema, type FieldType } from './field.zod'; +import { FieldSchema, FieldType } from './field.zod'; import { ValidationRuleSchema } from './validation.zod'; import { ActionSchema, type ActionParam } from '../ui/action.zod'; import { ObjectListViewSchema } from '../ui/view.zod'; @@ -1693,6 +1693,124 @@ function refuseNonPictureImageField( }); } +/** + * The machine-name grammar an attached block and its leaves share with field + * names — they are keys on the same served row (`record..`). + */ +const ATTACHED_ON_READ_NAME = /^[a-z_][a-z0-9_]*$/; + +/** + * [#22211 ruling A] `ObjectSchema.attachedOnRead` — the blocks a service + * attaches to the rows it serves, computed per caller on read and never stored. + * + * The worked case is plugin-approvals: `ApprovalService.attachViewers` sets + * `viewer: { can_act, is_submitter, can_override }` on every `sys_approval_request` + * row it reads, from the CALLER's identity, and the object's own action + * predicates gate on `record.viewer.can_act`. Before this key nothing could + * declare such a block in a shape the build validator reads, so those + * predicates were refused as naming an undeclared field. + * + * ## Shape — a strict record of strict records + * + * Block name → (leaf key → value type). Both key levels take the field-name + * grammar, and a leaf's type is one of the four value types a row value can + * carry when it is computed rather than stored — the same four + * `Field.returnType` declares for a formula, taken from {@link FieldType} so + * the vocabulary cannot drift from the field types the validator already knows. + * Anything else — a nested block, a field definition, a type outside the four — + * is refused at parse, located at the offending key. + * + * ## What it is NOT + * + * Not a field. It provisions no column, and no driver, form, list view, export, + * write path or translation bundle reads it — a block exists only on a row a + * service has served, never on the row a write carries. For the same reason a + * block may not reuse a declared field name, and a block names at least one + * leaf — both refused by {@link refuseAttachedBlockConflicts}. + * + * ## Its reader (ADR-0049 enforce-or-remove) + * + * The shared BUILD validator — `@objectstack/lint`'s expression rule over + * `@objectstack/formula`, the pair `os build` / `os validate` run, and no other + * field-existence check. `@objectstack/lint`'s field index adds every + * declared block name to the names `record.` may resolve to, and + * `@objectstack/formula`'s field-existence pass judges the SECOND segment of + * `record..` against the block's declared leaves, under its + * existing `unknown-field` refusal — so `record.viewer.can_actt` is refused and + * the refusal names the leaves `viewer` declares. An object without this key + * keeps exactly the verdicts it had. Two other doors build their own + * `record.*` field set from `fields` alone and do not read this key — the MCP + * expression tool (`packages/mcp`) and flow registration's schema resolver + * (`service-automation`); the first package to declare a block settles them + * (#22387). + * + * The collision refusal reads the AUTHORED field map only. A block named like + * an injected system column (`id`, `organization_id`, the audit family, the + * ownership anchors) is not refused here: the per-object derivation, + * `resolveInjectedSystemColumns`, lives in a module that imports this one, and + * its column-name constants are module-private, so this file cannot reach + * them without an import cycle or a new export. + * + * The validator judges names only. The leaf TYPES are for the declaring + * package's conformance test to read: it pins that the keys its service emits, + * and the runtime type of each emitted value, equal this declaration + * (plugin-approvals' `viewer`, #22387). + */ +const AttachedOnReadSchema = z.record( + z.string().regex(ATTACHED_ON_READ_NAME, { + message: 'Attached block names must be lowercase snake_case (e.g. "viewer"), like field names.', + }), + z.record( + z.string().regex(ATTACHED_ON_READ_NAME, { + message: 'Attached leaf keys must be lowercase snake_case (e.g. "can_act"), like field names.', + }), + FieldType.extract(['number', 'text', 'boolean', 'date']), + ), +); + +/** + * [#22211 ruling A] The two `attachedOnRead` refusals that need more than the + * record's own grammar — see {@link AttachedOnReadSchema}. One located issue + * per offending block, at `['attachedOnRead', ]`: + * + * - a block that names NO leaf — it would make every `record..` + * a refusal while claiming the service attaches something; + * - a block that reuses the name of a field this object declares — one name + * cannot be both a stored column and a per-caller block. + * + * Judged here, in the object's `superRefine` beside + * {@link refuseNonPictureImageField}, rather than as a refinement on the record + * itself: the field half needs the object's own field map, and keeping both + * halves in the existing callback adds no check node the JSON Schema + * projection would have to drop. Collisions are judged against the AUTHORED + * field map. + */ +function refuseAttachedBlockConflicts(attachedOnRead: unknown, fields: unknown, ctx: z.RefinementCtx): void { + if (attachedOnRead === null || typeof attachedOnRead !== 'object') return; + for (const [block, leaves] of Object.entries(attachedOnRead)) { + if (leaves !== null && typeof leaves === 'object' && Object.keys(leaves).length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['attachedOnRead', block], + message: + `\`attachedOnRead\` declares the block \`${block}\` with no leaves. A block names the leaf keys its ` + + `service attaches and their value types (e.g. \`${block}: { can_act: 'boolean' }\`); declare them, ` + + 'or remove the block.', + }); + } + if (fields !== null && typeof fields === 'object' && Object.prototype.hasOwnProperty.call(fields, block)) { + ctx.addIssue({ + code: 'custom', + path: ['attachedOnRead', block], + message: + `\`attachedOnRead\` declares a block \`${block}\`, but \`${block}\` is also a declared field of this object. ` + + 'A read attachment is computed per caller and never stored, so it cannot share a name with a stored ' + + 'column — rename the block, or remove it if the field is what the expression should read.', + }); + } + } +} + // ⚠️ ORDER IS LOAD-BEARING (#5593). This map used to live ~700 lines BELOW // `ObjectSchemaBase`, and the error map that reads it was built lazily // (`objectUnknownKeyErrorImpl ??= …`) purely to step around the temporal dead @@ -2121,6 +2239,20 @@ const ObjectSchemaBase = strictObject( ), 'fields', ).describe('Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused.'), + + /** + * Read attachments — the blocks a service attaches to the rows it serves, + * computed per caller and never stored (#22211, ruling A). See + * {@link AttachedOnReadSchema} for the shape, the reader and what it is not. + */ + attachedOnRead: AttachedOnReadSchema.optional().describe( + 'Blocks a service attaches to each row it serves, computed per caller on read and never stored: ' + + 'block name → { leaf key → value type (number | text | boolean | date) }. NOT a field — no column, ' + + 'form, list view, export, write path or translation bundle reads it, and a block name may not repeat ' + + 'a declared field name. Its reader is the shared build validator (`@objectstack/lint` over ' + + '`@objectstack/formula`, as `os build` / `os validate` run it): `record.` resolves, and ' + + '`record..` resolves only to a leaf the block declares.', + ), indexes: z.array(IndexSchema).optional().describe('Database performance indexes'), /** @@ -2573,6 +2705,9 @@ const ObjectSchemaBase = strictObject( // object — the same door, for the same reason: only the object holds the // field map the pointer resolves against. refuseNonPictureImageField(object.name, object.imageField, object.fields, ctx); + // [#22211 ruling A] A read attachment names at least one leaf, and is not a + // field: its block names may not repeat a declared field name. + refuseAttachedBlockConflicts(object.attachedOnRead, object.fields, ctx); }); /** diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index a9c7828c955..1ea5190ed65 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -489,6 +489,13 @@ const LEDGER: ReadonlyArray = [ key: 'systemFields', why: "code-declared platform configuration (ruling record 5861442317): an isolation switch, where `{ tenant: false }` withholds `organization_id` and reads as the tenancy opt-out on the object's security posture, and `false` withholds every injected column, the audit family included (`resolveInjectedSystemColumns`), written only by a platform object declared in code (`sys_metadata_activation`, `{ tenant: false }`), so a form row would put both one click away", }, + { + kind: 'omit', + type: 'object', + path: ROOT_PATH, + key: 'attachedOnRead', + why: "not a form's to offer, by ruling (ruling record 6070963704, #22211 A): the blocks a service attaches to the rows it serves, computed per caller and never stored, which the ruling says no form reads; a block is declared in code beside the service that attaches it (plugin-approvals' `viewer` on `sys_approval_request`), because nothing authored in a designer can make a service attach a block, and its one reader is the shared build validator", + }, { kind: 'omit', type: 'app',