Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/22386-spec-object-attached-on-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/spec": minor
---

`ObjectSchema.attachedOnRead`: an object declares the blocks a service attaches to each row it serves, computed per caller on read and never stored

Clause-②: yes (widening: a new optional `ObjectSchema` key)

- **The key.** `attachedOnRead` is an optional map from block name to that block's leaves, each leaf naming its value type: `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. Block names and leaf keys take the field-name grammar (lowercase snake_case). A leaf's type is one of `number`, `text`, `boolean` or `date`, the four value types `Field.returnType` declares.
- **It is not a field.** It provisions no column, and no driver, form, list view, export, write path or translation bundle reads it. Its reader is the shared build validator, `@objectstack/lint`'s expression rule over `@objectstack/formula` (what `os build` and `os validate` run): `record.<block>` resolves, and `record.<block>.<leaf>` 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.
12 changes: 12 additions & 0 deletions .changeset/22386-validator-attached-on-read-leaf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/formula": minor
"@objectstack/lint": patch
"@objectstack/metadata-core": patch
---

The shared build validator (`@objectstack/lint`'s expression rule over `@objectstack/formula`) judges `record.<block>.<leaf>` 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.<x>` 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.
1 change: 1 addition & 0 deletions content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **attachedOnRead** | `Record<string, Record<string, Enum<'number' \| 'text' \| 'boolean' \| 'date'>>>` | 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.<block>` resolves, and `record.<block>.<leaf>` 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 |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **attachedOnRead** | `Record<string, Record<string, Enum<'number' \| 'text' \| 'boolean' \| 'date'>>>` | 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.<block>` resolves, and `record.<block>.<leaf>` 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 |
Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/system/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **attachedOnRead** | `Record<string, Record<string, Enum<'number' \| 'text' \| 'boolean' \| 'date'>>>` | 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.<block>` resolves, and `record.<block>.<leaf>` 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 |
Expand Down Expand Up @@ -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<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **attachedOnRead** | `Record<string, Record<string, Enum<'number' \| 'text' \| 'boolean' \| 'date'>>>` | 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.<block>` resolves, and `record.<block>.<leaf>` 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 |
Expand Down
20 changes: 20 additions & 0 deletions packages/formula/src/expression-refusal-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,26 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
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': [
{
Expand Down
17 changes: 15 additions & 2 deletions packages/formula/src/expression-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<field>` naming no field of the object. */
'unknown-field': { readonly field: string; readonly objectName?: string; readonly suggestion?: string };
/**
* `record.<field>` naming no field of the object — or, for a declared read
* attachment (`ExprSchemaHint.attachedOnRead`), `record.<block>.<leaf>`
* 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. */
Expand Down
132 changes: 132 additions & 0 deletions packages/formula/src/validate-attached-on-read.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `ExprSchemaHint.attachedOnRead` — the second segment of
* `record.<block>.<leaf>` 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.<block>.<leaf>', () => {
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([]);
});
});
Loading
Loading