From 67d9a614febc93e018a68f82767c0f70ab9ed630 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:59:12 +0000 Subject: [PATCH 1/8] =?UTF-8?q?wip(spec):=20ObjectSchema.attachedOnRead=20?= =?UTF-8?q?=E2=80=94=20declare=20per-caller=20read=20attachments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/src/data/object.zod.ts | 100 ++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 20fb460d6f0..be9ca5fa609 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,88 @@ 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 expression 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 (refused in this schema's + * `superRefine`): one name cannot be both a stored column and a per-caller block. + * + * ## Its reader (ADR-0049 enforce-or-remove) + * + * The shared expression validator. `@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. + */ +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] A block of `attachedOnRead` may not reuse the name of a + * field this object declares — see {@link AttachedOnReadSchema}. One located + * issue per colliding block, at `['attachedOnRead', ]`. + * + * Judged against the AUTHORED field map, in the object's `superRefine` beside + * {@link refuseNonPictureImageField}, for the same reason: only the object holds + * both maps. + */ +function refuseAttachedBlockFieldCollision(attachedOnRead: unknown, fields: unknown, ctx: z.RefinementCtx): void { + if (attachedOnRead === null || typeof attachedOnRead !== 'object') return; + if (fields === null || typeof fields !== 'object') return; + for (const block of Object.keys(attachedOnRead)) { + if (!Object.prototype.hasOwnProperty.call(fields, block)) continue; + 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 +2203,19 @@ 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 expression validator: `record.` resolves, and ' + + '`record..` resolves only to a leaf the block declares.', + ), indexes: z.array(IndexSchema).optional().describe('Database performance indexes'), /** @@ -2573,6 +2668,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 is not a field: its block names may not + // repeat a declared field name. + refuseAttachedBlockFieldCollision(object.attachedOnRead, object.fields, ctx); }); /** From cb641d6c3a8d6d542eeec970d6221147b2b5d96e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:22:34 +0000 Subject: [PATCH 2/8] wip(lint,formula): judge record.. against attachedOnRead; pins + ledger row Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../src/validate-attached-on-read.test.ts | 132 ++++++++++++++++++ packages/formula/src/validate.ts | 89 +++++++++++- ...idate-expressions.attached-on-read.test.ts | 118 ++++++++++++++++ .../lint/src/validate-expressions.test.ts | 8 +- packages/lint/src/validate-expressions.ts | 68 ++++++++- packages/spec/authorable-surface/data.json | 1 + packages/spec/liveness/object.json | 8 ++ .../src/data/object-attached-on-read.test.ts | 115 +++++++++++++++ packages/spec/src/data/object.zod.ts | 66 +++++---- 9 files changed, 576 insertions(+), 29 deletions(-) create mode 100644 packages/formula/src/validate-attached-on-read.test.ts create mode 100644 packages/lint/src/validate-expressions.attached-on-read.test.ts create mode 100644 packages/spec/src/data/object-attached-on-read.test.ts 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 85de72b6c5a..f422337fc62 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -2912,7 +2912,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), }, @@ -3116,6 +3118,10 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t // `grammar` excuse #19938 added here left with the import it excused: // this file no longer imports `'./flow-template-grammar.js'`.) 'templateRefusal', + // [#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 95452c572bc..3e67811e765 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` | @@ -171,7 +172,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; } @@ -1320,6 +1371,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); @@ -1471,8 +1523,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' }); @@ -2075,7 +2129,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/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..90756eddcbd 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 expression validator 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. 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 are declared (the four `Field.returnType` value types) and are not read by this consumer, which judges names only; the ruling's second reader, plugin-approvals' conformance test pinning that the keys `attachViewers` emits equal the keys `sys_approval_request` declares, lands with #22387." + }, "datasource": { "status": "live", "verifiedAt": "2026-08-28", 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 be9ca5fa609..d4effd4efcd 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1725,8 +1725,8 @@ const ATTACHED_ON_READ_NAME = /^[a-z_][a-z0-9_]*$/; * 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 (refused in this schema's - * `superRefine`): one name cannot be both a stored column and a per-caller block. + * 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) * @@ -1751,27 +1751,45 @@ const AttachedOnReadSchema = z.record( ); /** - * [#22211 ruling A] A block of `attachedOnRead` may not reuse the name of a - * field this object declares — see {@link AttachedOnReadSchema}. One located - * issue per colliding block, at `['attachedOnRead', ]`. - * - * Judged against the AUTHORED field map, in the object's `superRefine` beside - * {@link refuseNonPictureImageField}, for the same reason: only the object holds - * both maps. + * [#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 refuseAttachedBlockFieldCollision(attachedOnRead: unknown, fields: unknown, ctx: z.RefinementCtx): void { +function refuseAttachedBlockConflicts(attachedOnRead: unknown, fields: unknown, ctx: z.RefinementCtx): void { if (attachedOnRead === null || typeof attachedOnRead !== 'object') return; - if (fields === null || typeof fields !== 'object') return; - for (const block of Object.keys(attachedOnRead)) { - if (!Object.prototype.hasOwnProperty.call(fields, block)) continue; - 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.', - }); + 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.', + }); + } } } @@ -2668,9 +2686,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 is not a field: its block names may not - // repeat a declared field name. - refuseAttachedBlockFieldCollision(object.attachedOnRead, 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); }); /** From dd01520df6468292c106e03600e06cbc058eca65 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:35:56 +0000 Subject: [PATCH 3/8] wip(spec): regenerate liveness state counts; changesets Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .changeset/22386-spec-object-attached-on-read.md | 12 ++++++++++++ .changeset/22386-validator-attached-on-read-leaf.md | 10 ++++++++++ packages/spec/liveness/state-counts/object.md | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .changeset/22386-spec-object-attached-on-read.md create mode 100644 .changeset/22386-validator-attached-on-read-leaf.md 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..1824edd273a --- /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 expression validator: `record.` resolves, and `record..` resolves only to a leaf the block declares (see the `@objectstack/formula` and `@objectstack/lint` entries). +- **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..44308a4cd75 --- /dev/null +++ b/.changeset/22386-validator-attached-on-read-leaf.md @@ -0,0 +1,10 @@ +--- +"@objectstack/formula": patch +"@objectstack/lint": patch +--- + +The shared expression validator 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. +- **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/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 | From 37711ce70b3b1119b9668ca6156f373213e4a8aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:37:35 +0000 Subject: [PATCH 4/8] wip(spec): regenerate reference docs (gen:docs) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/references/api/metadata.mdx | 1 + content/docs/references/data/object.mdx | 1 + content/docs/references/system/migration.mdx | 2 ++ 3 files changed, 4 insertions(+) diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index a8e855d1901..d8bebd5b547 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 expression validator: `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..177771e20cf 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 expression validator: `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..22f1c9619d1 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 expression validator: `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 expression validator: `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 | From 59817e1c09438bb226bc48bbefa9da0c754e8e86 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 04:35:34 +0000 Subject: [PATCH 5/8] feat(spec,formula,metadata-core): classify attachedOnRead in the closed-world pins; type the unknown-field leaf params Seat order 6074312931 widens the surface by the five measured files: the ADR-0106 FLS position row, the object form ledger's root omit row, the two composeStacks collection-list pins, and the unknown-field block/leaves params with their codes pin. Formula changeset to minor; the liveness note names the declaring package's conformance test as the leaf types' reader. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../22386-validator-attached-on-read-leaf.md | 4 +++- .../src/expression-refusal-codes.test.ts | 20 +++++++++++++++++++ packages/formula/src/expression-refusal.ts | 17 ++++++++++++++-- .../src/object-schema-fls-references.ts | 4 ++++ packages/spec/liveness/object.json | 2 +- ...compose-stacks-collection-pipe-arm.test.ts | 2 ++ ...se-stacks-merge-collection-refusal.test.ts | 2 ++ packages/spec/src/data/object.zod.ts | 5 +++++ .../metadata-form-zod-reconciliation.test.ts | 7 +++++++ 9 files changed, 59 insertions(+), 4 deletions(-) diff --git a/.changeset/22386-validator-attached-on-read-leaf.md b/.changeset/22386-validator-attached-on-read-leaf.md index 44308a4cd75..e49d89a3de5 100644 --- a/.changeset/22386-validator-attached-on-read-leaf.md +++ b/.changeset/22386-validator-attached-on-read-leaf.md @@ -1,10 +1,12 @@ --- -"@objectstack/formula": patch +"@objectstack/formula": minor "@objectstack/lint": patch +"@objectstack/metadata-core": patch --- The shared expression validator 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/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/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/spec/liveness/object.json b/packages/spec/liveness/object.json index 90756eddcbd..0e0f9dd9282 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -76,7 +76,7 @@ "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 expression validator 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. 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 are declared (the four `Field.returnType` value types) and are not read by this consumer, which judges names only; the ruling's second reader, plugin-approvals' conformance test pinning that the keys `attachViewers` emits equal the keys `sys_approval_request` declares, lands with #22387." + "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 expression validator 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. 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", 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.zod.ts b/packages/spec/src/data/object.zod.ts index d4effd4efcd..af5b9d7513a 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1737,6 +1737,11 @@ const ATTACHED_ON_READ_NAME = /^[a-z_][a-z0-9_]*$/; * 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. + * + * 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, { 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..61015bcc174 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 expression validator", + }, { kind: 'omit', type: 'app', From 3b09ab36a7281622e4bb63905c0aadb77d33c42d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 06:17:36 +0000 Subject: [PATCH 6/8] fix(spec,metadata-protocol): visit the served object property-count pin; name the build validator as the reader The served object schema gains attachedOnRead, so CARD_PROPERTY_COUNTS.object moves 44 to 45 with the docblock sentence its precedents carry. The describe text, docblock, ledger note and changesets now name the shared build validator (lint over formula) as the reader, and record that the injected-column collision is out of reach of object.zod.ts. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../22386-spec-object-attached-on-read.md | 2 +- .../22386-validator-attached-on-read-leaf.md | 2 +- ...l.meta-types-degenerate-derivation.test.ts | 6 +++-- packages/spec/liveness/object.json | 2 +- packages/spec/src/data/object.zod.ts | 22 +++++++++++++++---- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.changeset/22386-spec-object-attached-on-read.md b/.changeset/22386-spec-object-attached-on-read.md index 1824edd273a..3c65c049b18 100644 --- a/.changeset/22386-spec-object-attached-on-read.md +++ b/.changeset/22386-spec-object-attached-on-read.md @@ -7,6 +7,6 @@ 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 expression validator: `record.` resolves, and `record..` resolves only to a leaf the block declares (see the `@objectstack/formula` and `@objectstack/lint` entries). +- **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 index e49d89a3de5..9e8ff7dc20a 100644 --- a/.changeset/22386-validator-attached-on-read-leaf.md +++ b/.changeset/22386-validator-attached-on-read-leaf.md @@ -4,7 +4,7 @@ "@objectstack/metadata-core": patch --- -The shared expression validator judges `record..` against an object's declared read attachments (`ObjectSchema.attachedOnRead`) +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. 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/liveness/object.json b/packages/spec/liveness/object.json index 0e0f9dd9282..11c257516b2 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -76,7 +76,7 @@ "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 expression validator 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. 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." + "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", diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index af5b9d7513a..6c553b0e2ec 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1707,7 +1707,7 @@ const ATTACHED_ON_READ_NAME = /^[a-z_][a-z0-9_]*$/; * `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 expression validator reads, so those + * 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 @@ -1730,13 +1730,26 @@ const ATTACHED_ON_READ_NAME = /^[a-z_][a-z0-9_]*$/; * * ## Its reader (ADR-0049 enforce-or-remove) * - * The shared expression validator. `@objectstack/lint`'s field index adds every + * 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. + * 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, @@ -2236,7 +2249,8 @@ const ObjectSchemaBase = strictObject( '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 expression validator: `record.` resolves, and ' + + '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'), From a0545b853fc24b707a9433380cbea7b60ed973f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 06:19:55 +0000 Subject: [PATCH 7/8] chore(spec): regenerate reference docs for the narrowed attachedOnRead describe Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/references/api/metadata.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/system/migration.mdx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index d8bebd5b547..dfba6ebac66 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -948,7 +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 expression validator: `record.` resolves, and `record..` resolves only to a leaf the block declares. | +| **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 177771e20cf..5ee0bcffd7d 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -152,7 +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 expression validator: `record.` resolves, and `record..` resolves only to a leaf the block declares. | +| **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 22f1c9619d1..f9e83c7062d 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -329,7 +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 expression validator: `record.` resolves, and `record..` resolves only to a leaf the block declares. | +| **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 | @@ -617,7 +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 expression validator: `record.` resolves, and `record..` resolves only to a leaf the block declares. | +| **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 | From e19c1e349d35dca7d8cef7ce405a421626b54200 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:44:29 +0000 Subject: [PATCH 8/8] test(spec): the attachedOnRead form-ledger row names the shared build validator as its reader Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../spec/src/system/metadata-form-zod-reconciliation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 61015bcc174..1ea5190ed65 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -494,7 +494,7 @@ const LEDGER: ReadonlyArray = [ 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 expression validator", + 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',