From ce338a72a203b847f36c584171b7a73d1f8c90c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:03:24 +0000 Subject: [PATCH 1/4] fix(lint): a declared read attachment resolves only at the served-row sites, so a flow condition reading it is refused The field-existence set every expression site reads is the object's columns again. A declared attachedOnRead block joins it, and its leaves are judged, only at the sites listed in SERVED_ROW_SITES: an action's visible and disabled predicates, whose record is the row the surface fetched. Flow conditions, validation rules, field-rule slots, option visibleWhen, field formulas, sharing rules and hooks bind the stored row, which never carries a block, and refuse record.BLOCK as an unknown field. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- ...idate-expressions.attached-on-read.test.ts | 183 +++++++++++++++--- packages/lint/src/validate-expressions.ts | 111 ++++++++--- 2 files changed, 244 insertions(+), 50 deletions(-) diff --git a/packages/lint/src/validate-expressions.attached-on-read.test.ts b/packages/lint/src/validate-expressions.attached-on-read.test.ts index 1921a07774c..c29777e2885 100644 --- a/packages/lint/src/validate-expressions.attached-on-read.test.ts +++ b/packages/lint/src/validate-expressions.attached-on-read.test.ts @@ -1,23 +1,26 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#22211 ruling A, #22386] `buildFieldIndex` and the shared validator read - * `ObjectSchema.attachedOnRead`. + * `ObjectSchema.attachedOnRead`, read by `buildAttachedOnReadIndex` and the + * shared validator — and only at the sites whose `record` is a served row. * * 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 + * per caller and never stored. At a served-row site (`SERVED_ROW_SITES`: an + * action's `visible` and `disabled`), 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. + * name. Every other site binds the stored row, which never carries a block, + * so there `record.` is refused as the unknown field it is at run time. + * 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). + * branch no author can reach. * - * 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. + * The probe object mirrors the shipped shape — an action `visible` predicate + * over `record.viewer.can_act` — without being the shipped object, which + * plugin-approvals judges in its own test. */ import { describe, it, expect } from 'vitest'; @@ -63,7 +66,115 @@ function issuesFor(visible: string, declared: boolean) { ); } -describe('attachedOnRead — record.. through the stack rule', () => { +/** One action whose `disabled` predicate is the expression under test. */ +function disabledIssuesFor(disabled: string) { + return validateStackExpressions( + specValid({ + objects: [probe(true)], + actions: [{ name: 'approve_probe', label: 'Approve', objectName: 'approval_probe', type: 'script', target: 'fn', disabled }], + }), + ); +} + +/** A record-change flow on the probe object, its start gate and its one edge carrying the given conditions. */ +const flowOn = (gate: { start?: string; edge?: string }) => ({ + name: 'probe_flow', + label: 'Probe flow', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { + objectName: 'approval_probe', + triggerType: 'record-after-update', + ...(gate.start ? { condition: gate.start } : {}), + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end', ...(gate.edge ? { condition: gate.edge } : {}) }], +}); + +/** The declared probe object with extra fields or validations merged in. */ +const declaredWith = (extra: { fields?: Record; validations?: unknown[] }) => ({ + ...probe(true), + fields: { ...probe(true).fields, ...(extra.fields ?? {}) }, + ...(extra.validations ? { validations: extra.validations } : {}), +}); + +const READ = 'record.viewer.can_act == true'; + +/** + * Every site that binds the STORED row, each carrying one read of the block on + * an object that declares it: the stack, and where the site's finding is + * located. + */ +const STORED_ROW_SITES: ReadonlyArray Record, string]> = [ + [ + 'a flow start condition', + () => ({ objects: [probe(true)], flows: [flowOn({ start: READ })] }), + "flow 'probe_flow' · node 'start' (start) condition", + ], + [ + 'a flow edge condition', + () => ({ objects: [probe(true)], flows: [flowOn({ edge: READ })] }), + "flow 'probe_flow' · edge 'e1' (start→end) condition", + ], + [ + 'a validation rule', + () => ({ objects: [declaredWith({ validations: [{ type: 'script', name: 'probe_rule', message: 'm', condition: READ }] })] }), + "object 'approval_probe' · validation 'probe_rule'", + ], + [ + "a field's requiredWhen", + () => ({ objects: [declaredWith({ fields: { note: { type: 'text', label: 'Note', requiredWhen: READ } } })] }), + "object 'approval_probe' · field 'note' requiredWhen", + ], + [ + "a field's visibleWhen", + () => ({ objects: [declaredWith({ fields: { note: { type: 'text', label: 'Note', visibleWhen: READ } } })] }), + "object 'approval_probe' · field 'note' visibleWhen", + ], + [ + "an option's visibleWhen", + () => ({ + objects: [declaredWith({ + fields: { + stage: { type: 'select', label: 'Stage', options: [{ label: 'Open', value: 'open', visibleWhen: READ }] }, + }, + })], + }), + "object 'approval_probe' · field 'stage' option 'open' visibleWhen", + ], + [ + 'a field formula', + () => ({ + objects: [declaredWith({ fields: { flagged: { type: 'formula', label: 'Flagged', expression: 'record.viewer.can_act ? 1 : 0' } } })], + }), + "object 'approval_probe' · field 'flagged' expression", + ], + [ + 'a sharing-rule condition', + () => ({ + objects: [probe(true)], + sharingRules: [{ name: 'probe_share', type: 'criteria', object: 'approval_probe', sharedWith: { type: 'team', value: 't' }, condition: READ }], + }), + "sharingRule 'probe_share' (approval_probe) condition", + ], + [ + 'a hook condition', + () => ({ + objects: [probe(true)], + hooks: [{ name: 'probe_hook', object: 'approval_probe', events: ['afterUpdate'], handler: 'probe_fn', condition: READ }], + }), + "hook 'probe_hook' (approval_probe) condition", + ], +]; + +describe('attachedOnRead — record.. at a served-row site', () => { 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([]); @@ -86,6 +197,14 @@ describe('attachedOnRead — record.. through the stack rule', () = expect(issues[0].message).toMatch(/^unknown field `viewr` on `approval_probe` — did you mean `viewer`\?$/); }); + it('an action `disabled` predicate is a served-row site too', () => { + expect(disabledIssuesFor('record.viewer.can_act == false')).toEqual([]); + const issues = disabledIssuesFor('record.viewer.can_actt == false'); + expect(issues).toHaveLength(1); + expect(issues[0].where).toBe("stack · action 'approve_probe' disabled"); + expect(issues[0].message).toContain('unknown field `viewer.can_actt` on `approval_probe`'); + }); + it('an object without the key keeps today’s verdict (control)', () => { const issues = issuesFor('record.viewer.can_act', false); expect(issues).toHaveLength(1); @@ -93,26 +212,34 @@ describe('attachedOnRead — record.. through the stack rule', () = // 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'); +describe('attachedOnRead — a stored-row site resolves the object’s columns alone', () => { + it.each(STORED_ROW_SITES)('%s: the block is an unknown field, as on an object that declares none', (_site, stack, where) => { + const issues = validateStackExpressions(specValid(stack())); + expect(issues, JSON.stringify(issues, null, 2)).toHaveLength(1); + expect(issues[0].where).toBe(where); + expect(issues[0].severity).toBe('error'); + expect(issues[0].message).toBe('unknown field `viewer` on `approval_probe`'); + }); + + it('the block is no "did you mean?" candidate there either', () => { + const issues = validateStackExpressions(specValid({ objects: [probe(true)], flows: [flowOn({ start: 'record.viewr.can_act == true' })] })); 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`'); + expect(issues[0].message).toMatch(/^unknown field `viewr` on `approval_probe`/); + expect(issues[0].message).not.toContain('viewer'); + }); + + it('one object, two sites: its action predicate reads the block, its flow condition may not', () => { + const issues = validateStackExpressions( + specValid({ + objects: [probe(true)], + actions: [{ name: 'approve_probe', label: 'Approve', objectName: 'approval_probe', type: 'script', target: 'fn', visible: READ }], + flows: [flowOn({ start: READ })], + }), + ); + expect(issues.map((i) => [i.where, i.message])).toEqual([ + ["flow 'probe_flow' · node 'start' (start) condition", 'unknown field `viewer` on `approval_probe`'], + ]); }); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 3edaa4b67ac..08321405178 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -180,12 +180,11 @@ 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. - // [#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)])]); + // A declared read attachment is NOT here: it is not a column, so a stored + // row never carries it, and this index is what every stored-row site + // reads. The sites that bind a served row add the blocks themselves — see + // {@link SERVED_ROW_SITES} and {@link withAttachedBlocks}. + idx.set(name, [...new Set([...names, ...injectedColumnsFor(obj)])]); } return idx; } @@ -212,17 +211,14 @@ function attachedOnReadOf(obj: AnyRec): Record { 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. + * object name → its declared read attachments (block name → leaf keys), for + * the served-row sites ({@link SERVED_ROW_SITES}): there each block name joins + * the field-existence set, and the shared validator judges the second segment + * of `record..` against the block's leaves + * (`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>(); @@ -235,6 +231,63 @@ function buildAttachedOnReadIndex(objects: AnyRec[]): Map | undefined, +): string[] | undefined { + if (!blocks) return fields; + return [...new Set([...(fields ?? []), ...Object.keys(blocks)])]; +} + /** * [#8116] The roots this file's unprovisioned-anchor warning resolves against * the bound object — the same pair the #4763 null-guard gate resolves, for the @@ -1849,14 +1902,23 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression * on the fail-open seams that means the rule stops enforcing entirely. */ traversalHydration?: boolean, + /** + * Set only where this site's `record` is a row a service serves — an entry + * of {@link SERVED_ROW_SITES}. There the object's declared read + * attachments join the field-existence set and their leaves are judged; + * absent, the site binds the stored row and resolves the object's columns + * alone, which is every site that does not name one. + */ + servedRowSite?: ServedRowSite, ): void => { if (raw == null) return; - const fields = objectName ? fieldIndex.get(objectName) : undefined; + // Absent unless this is a served-row site on an object that declares a + // read attachment. + const attachedOnRead = objectName && servedRowSite ? attachedOnReadIndex.get(objectName) : undefined; + const fields = objectName ? withAttachedBlocks(fieldIndex.get(objectName), attachedOnRead) : undefined; // 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, attachedOnRead, fieldTypes, scope, traversalHydration } : { scope }); for (const e of res.errors) { @@ -2479,12 +2541,15 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // writes `(record.budget == null ? 0 : record.budget) - …`), so the cost of // deciding later is low. Raise it as its own issue rather than widening // this call. Ledger: `validate-null-guards.ts`. + // + // The object's columns alone, never its read attachments: a formula is + // computed on the stored row, which carries no block (see + // {@link SERVED_ROW_SITES}). const res = validateExpression('value', f.expression as string | { dialect?: string; source?: string }, objectName ? { objectName, fields: fieldIndex.get(objectName), - attachedOnRead: attachedOnReadIndex.get(objectName), fieldTypes: fieldTypeIndex.get(objectName), scope: 'record', } @@ -2700,9 +2765,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression const key = `${obj ?? ''}:${name}`; if (seenActions.has(key)) return; // de-dup (actions are merged onto objects AND kept top-level) seenActions.add(key); - check(`${where} · action '${name}' visible`, action.visible, obj, 'record'); + // The served-row sites: an action's predicates bind the row the surface + // fetched, so a declared read attachment resolves here and nowhere else. + check(`${where} · action '${name}' visible`, action.visible, obj, 'record', undefined, undefined, 'action visible'); if (typeof action.disabled !== 'boolean') { - check(`${where} · action '${name}' disabled`, action.disabled, obj, 'record'); + check(`${where} · action '${name}' disabled`, action.disabled, obj, 'record', undefined, undefined, 'action disabled'); } // No `checkNullGuards` here, and the reason is measured rather than assumed // (#4811). These predicates DO reach real CEL — a bare authored string is From 26088deb75ffaf8c65edbaa9908e6fa482bff22a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:13:28 +0000 Subject: [PATCH 2/4] docs(lint,spec): the attachedOnRead ledger row and the pass-4 note state the served-row reading; changeset The liveness row's evidence, producer and note named a field index that added every declared block at every site, and a conformance test that had not landed. They now name SERVED_ROW_SITES and the landed plugin-approvals conformance test. The pass-4 measurement note in authoring-rules.ts no longer says the object does not declare its viewer block. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .changeset/22481-attached-block-served-rows.md | 15 +++++++++++++++ packages/lint/src/authoring-rules.ts | 15 ++++++++++----- packages/spec/liveness/object.json | 6 +++--- 3 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 .changeset/22481-attached-block-served-rows.md diff --git a/.changeset/22481-attached-block-served-rows.md b/.changeset/22481-attached-block-served-rows.md new file mode 100644 index 00000000000..dae442488a3 --- /dev/null +++ b/.changeset/22481-attached-block-served-rows.md @@ -0,0 +1,15 @@ +--- +"@objectstack/lint": patch +--- + +A declared read attachment resolves only where `record` is a row a service serves, so `os validate` refuses a flow condition that reads one + +Clause-②: no + +`ObjectSchema.attachedOnRead` declares the blocks a service attaches to the rows it serves, computed per caller and never stored. An earlier entry in this release adds each declared block to the names `record.` resolves to, at every expression site bound to the object. That covered the sites whose `record` is the stored row, which never carries a block. There `os build`, `os validate` and the object save door accepted a read such as `record.viewer.can_act`, and the expression then faulted with `No such key: viewer` on every row. A record-change flow on `sys_approval_request` whose start condition read `record.viewer.can_act == true` validated, then failed on every record it fired for. + +A declared block now resolves, and its leaves are judged, only at the sites whose `record` is a served row: an action's `visible` and `disabled` predicates. + +- **Refused now.** On an object that declares a block, `record.BLOCK` at any other site gets the refusal it gets on an object that declares none: ``unknown field `viewer` on `sys_approval_request` ``, at `error`. Those sites are a flow's node and edge conditions, a validation rule, a field's `requiredWhen`, `readonlyWhen` and `visibleWhen`, an option's `visibleWhen`, a field formula, a sharing-rule condition and a hook condition. The block is no longer offered as a "did you mean?" candidate there either. The object save door runs the same rule over an object's own slots, so an object write in publish mode that reads a block outside an action predicate is refused with an `expression-invalid` issue. +- **Unchanged.** At an action predicate a declared leaf is accepted, and a misspelt leaf is refused with a message that names the leaves the block declares. `sys_approval_request`'s eight action predicates pass. No refusal code is added, and no export or signature moves. +- **Reach.** The only shipped object that declares a block is `sys_approval_request`, and its action predicates are the only shipped expressions that read it. The earlier entry has not been released, so no released version accepted a block read at these sites. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 8584dbe986e..0486b052387 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -670,11 +670,16 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // action predicates on 16 objects, and the example stacks as `defineStack` // composes them (33 objects, standalone actions merged in) carry 59 on 5 // → the build refuses 8, all `visible` on the platform's - // `sys_approval_request` (they read `record.viewer`, a block the approvals - // service attaches on read and the object does not declare; the producer - // fix is #22211), and the door refuses the same 8 after the lift and none - // before it. 0 other refusals and 0 advisories, against a refusal at each - // for a bare `amount > 1` action in the same harness. + // `sys_approval_request`: they read `record.viewer`, the block the + // approvals service attaches to the rows it serves, which the object did + // not declare when this was measured. The door refuses the same 8 after + // the lift and none before it. 0 other refusals and 0 advisories, against + // a refusal at each for a bare `amount > 1` action in the same harness. + // The object now declares that block under `attachedOnRead`, and an + // action predicate is a served-row site (`SERVED_ROW_SITES` in + // `validate-expressions.ts`), where a declared block resolves and its + // leaves are judged: the build and the door accept all 8, and refuse a + // misspelt leaf. surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow', 'action', 'hook', 'object'], run: (stack, ctx) => diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 11c257516b2..8f33a46b12d 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -74,9 +74,9 @@ "status": "live", "verifiedAt": "2026-10-09", "evidenceScope": "in-repo", - "evidence": "packages/lint/src/validate-expressions.ts#attachedOnReadOf (the read of `obj.attachedOnRead`: block name → its leaf keys); packages/lint/src/validate-expressions.ts#buildFieldIndex (each declared block name joins the names `record.` resolves to); packages/formula/src/validate.ts#checkAttachedLeaf (the second segment of `record..` judged against the block's declared leaves, under the existing `unknown-field` refusal)", - "producer": "packages/lint/src/validate-expressions.ts#runStackExpressionPasses (threads the per-object index into the shared validator's `attachedOnRead` hint at both call sites that pass the field index: the predicate `check` closure and the field-formula judge)", - "note": "#22211 ruling A (6070963704), built by #22386. `live` on an AUTHORING consumer, the `action.execution` / `dashboard.widgets.suppressWarnings` precedent: the key provisions nothing and changes no dispatch by itself — it is the declaration the shared build validator (`@objectstack/lint` over `@objectstack/formula`) judges `record.` and `record..` against, so `os build` / `os validate` and every door running the shared validator accept a declared leaf and refuse a misspelt one; deleting the key would delete that verdict. Two other field-existence builders read `fields` only and not this key — the MCP expression tool (packages/mcp) and service-automation's flow-registration resolver; their settlement is carried by #22387 (seat note 6075461181). By the ruling NOT a field: no driver, form, list view, export, write path or translation bundle reads it, and the schema refuses a block that repeats a declared field name. The leaf TYPES (the four `Field.returnType` value types) are not read by this consumer, which judges names only: their reader is the ruling's second reader, plugin-approvals' conformance test (#22387, seat order 6074312931), which pins that the keys `attachViewers` emits AND the runtime type of each emitted value equal what `sys_approval_request` declares under `attachedOnRead.viewer` (three `boolean`s). Until that test lands the types are a declaration awaiting its named reader." + "evidence": "packages/lint/src/validate-expressions.ts#attachedOnReadOf (the read of `obj.attachedOnRead`: block name → its leaf keys); packages/lint/src/validate-expressions.ts#SERVED_ROW_SITES (the sites whose `record` is a row a service serves, an action's `visible` and `disabled`: the only sites where 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 field-existence set and the shared validator's `attachedOnRead` hint at the served-row sites only: the predicate `check` closure does so for a call that names a `SERVED_ROW_SITES` entry, which only the action pass does; every other site, the field-formula judge included, resolves the object's columns alone)", + "note": "Maintainer ruling record 6070963704. `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. The ruling defines a block as attached to the rows a service SERVES, computed per caller and never stored, so the validator reads it only where `record` is such a row: an action's `visible` and `disabled` (`SERVED_ROW_SITES`). A flow condition, a validation rule, a field-rule slot, an option `visibleWhen`, a field formula, a sharing rule and a hook bind the stored row, which never carries a block, and there `record.` is refused as an unknown field — the fault it is at run time. 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; the second binds a flow's stored row, where the fields-only verdict is the served-row reading's own. 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 (packages/plugins/plugin-approvals/src/sys-approval-request-viewer.conformance.test.ts), 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), for every caller shape it serves." }, "datasource": { "status": "live", From 588abcb480267771955cd0e3b5559c0981a1c6d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:22:56 +0000 Subject: [PATCH 3/4] docs(spec): the attachedOnRead description and docblock state the served-row reading ObjectSchema.attachedOnRead's describe and the AttachedOnReadSchema docblock said record.BLOCK resolves wherever the validator reads the object. The validator now reads a declared block only in an action's visible and disabled predicates; every other site binds the stored row and refuses record.BLOCK as an unknown field. Both texts now say so. The changeset gains the @objectstack/spec patch entry. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../22481-attached-block-served-rows.md | 2 ++ packages/spec/src/data/object.zod.ts | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.changeset/22481-attached-block-served-rows.md b/.changeset/22481-attached-block-served-rows.md index dae442488a3..357e24beb13 100644 --- a/.changeset/22481-attached-block-served-rows.md +++ b/.changeset/22481-attached-block-served-rows.md @@ -1,5 +1,6 @@ --- "@objectstack/lint": patch +"@objectstack/spec": patch --- A declared read attachment resolves only where `record` is a row a service serves, so `os validate` refuses a flow condition that reads one @@ -12,4 +13,5 @@ A declared block now resolves, and its leaves are judged, only at the sites whos - **Refused now.** On an object that declares a block, `record.BLOCK` at any other site gets the refusal it gets on an object that declares none: ``unknown field `viewer` on `sys_approval_request` ``, at `error`. Those sites are a flow's node and edge conditions, a validation rule, a field's `requiredWhen`, `readonlyWhen` and `visibleWhen`, an option's `visibleWhen`, a field formula, a sharing-rule condition and a hook condition. The block is no longer offered as a "did you mean?" candidate there either. The object save door runs the same rule over an object's own slots, so an object write in publish mode that reads a block outside an action predicate is refused with an `expression-invalid` issue. - **Unchanged.** At an action predicate a declared leaf is accepted, and a misspelt leaf is refused with a message that names the leaves the block declares. `sys_approval_request`'s eight action predicates pass. No refusal code is added, and no export or signature moves. +- **`@objectstack/spec`.** The description of `ObjectSchema.attachedOnRead`, which the JSON schema and the reference pages carry, now says where the validator reads the key: in an action's `visible` and `disabled` predicates `record.` resolves and its leaves are judged, and every other expression site binds the stored row and refuses `record.` as an unknown field. The schema and its parse verdicts are unchanged. - **Reach.** The only shipped object that declares a block is `sys_approval_request`, and its action predicates are the only shipped expressions that read it. The earlier entry has not been released, so no released version accepted a block read at these sites. diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 6c553b0e2ec..758e3c6ca1e 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1732,17 +1732,23 @@ const ATTACHED_ON_READ_NAME = /^[a-z_][a-z0-9_]*$/; * * The shared BUILD validator — `@objectstack/lint`'s expression rule over * `@objectstack/formula`, the pair `os build` / `os validate` run, and no other - * field-existence check. `@objectstack/lint`'s field index adds every - * declared block name to the names `record.` may resolve to, and - * `@objectstack/formula`'s field-existence pass judges the SECOND segment of - * `record..` against the block's declared leaves, under its - * existing `unknown-field` refusal — so `record.viewer.can_actt` is refused and - * the refusal names the leaves `viewer` declares. An object without this key - * keeps exactly the verdicts it had. Two other doors build their own - * `record.*` field set from `fields` alone and do not read this key — the MCP - * expression tool (`packages/mcp`) and flow registration's schema resolver - * (`service-automation`); the first package to declare a block settles them - * (#22387). + * field-existence check. A block is on `record` only where `record` is a row + * the declaring service SERVED, so the validator reads this key only at those + * sites: an action's `visible` and `disabled` predicates (`SERVED_ROW_SITES` + * in `@objectstack/lint`). There each declared block name joins 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. Every other site binds the STORED row, which never + * carries a block — a flow's conditions, a validation rule, a field's rule + * slots and formula, an option's `visibleWhen`, a sharing rule, a hook — and + * there `record.` is refused as an unknown field, which is the fault it + * is at run time. An object without this key keeps exactly the verdicts it + * had. Two other doors build their own `record.*` field set from `fields` + * alone and do not read this key — the MCP expression tool (`packages/mcp`) + * and flow registration's schema resolver (`service-automation`); a flow binds + * the stored row, so the resolver's columns-only answer is this reading's own. * * The collision refusal reads the AUTHORED field map only. A block named like * an injected system column (`id`, `organization_id`, the audit family, the @@ -2250,8 +2256,11 @@ const ObjectSchemaBase = strictObject( + '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.', + + '`@objectstack/formula`, as `os build` / `os validate` run it), and only where `record` is a row the ' + + 'service served: in an action\'s `visible` and `disabled` predicates `record.` resolves, and ' + + '`record..` resolves only to a leaf the block declares. Every other expression site binds ' + + 'the stored row, which never carries a block — a flow condition, a validation rule, a field rule or ' + + 'formula, an option `visibleWhen`, a sharing rule, a hook — and refuses `record.` as an unknown field.', ), indexes: z.array(IndexSchema).optional().describe('Database performance indexes'), From 64277b3f28734efc9504f428584e496afdaff6a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:03:50 +0000 Subject: [PATCH 4/4] docs(spec): regenerate the reference pages for the attachedOnRead description Output of `pnpm --filter @objectstack/spec check:generated --fix`, which proved only content/docs/references/** stale (check:docs) after the describe change; the three pages that render ObjectSchema.attachedOnRead. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF 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 dfba6ebac66..9df38984c35 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 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. | +| **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), and only where `record` is a row the service served: in an action's `visible` and `disabled` predicates `record.` resolves, and `record..` resolves only to a leaf the block declares. Every other expression site binds the stored row, which never carries a block — a flow condition, a validation rule, a field rule or formula, an option `visibleWhen`, a sharing rule, a hook — and refuses `record.` as an unknown field. | | **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 5ee0bcffd7d..9f6fec1284f 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 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. | +| **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), and only where `record` is a row the service served: in an action's `visible` and `disabled` predicates `record.` resolves, and `record..` resolves only to a leaf the block declares. Every other expression site binds the stored row, which never carries a block — a flow condition, a validation rule, a field rule or formula, an option `visibleWhen`, a sharing rule, a hook — and refuses `record.` as an unknown field. | | **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 f9e83c7062d..5a35cf0a212 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 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. | +| **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), and only where `record` is a row the service served: in an action's `visible` and `disabled` predicates `record.` resolves, and `record..` resolves only to a leaf the block declares. Every other expression site binds the stored row, which never carries a block — a flow condition, a validation rule, a field rule or formula, an option `visibleWhen`, a sharing rule, a hook — and refuses `record.` as an unknown field. | | **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 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. | +| **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), and only where `record` is a row the service served: in an action's `visible` and `disabled` predicates `record.` resolves, and `record..` resolves only to a leaf the block declares. Every other expression site binds the stored row, which never carries a block — a flow condition, a validation rule, a field rule or formula, an option `visibleWhen`, a sharing rule, a hook — and refuses `record.` as an unknown field. | | **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 |