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
17 changes: 17 additions & 0 deletions .changeset/22481-attached-block-served-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@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

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.<x>` 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.
- **`@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.<block>` resolves and its leaves are judged, and every other expression site binds the stored row and refuses `record.<block>` 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.
2 changes: 1 addition & 1 deletion content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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. |
| **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), and only where `record` is a row the service served: in an action's `visible` and `disabled` predicates `record.<block>` resolves, and `record.<block>.<leaf>` 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.<block>` 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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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. |
| **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), and only where `record` is a row the service served: in an action's `visible` and `disabled` predicates `record.<block>` resolves, and `record.<block>.<leaf>` 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.<block>` 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 |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/system/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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. |
| **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), and only where `record` is a row the service served: in an action's `visible` and `disabled` predicates `record.<block>` resolves, and `record.<block>.<leaf>` 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.<block>` 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 |
Expand Down Expand Up @@ -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<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. |
| **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), and only where `record` is a row the service served: in an action's `visible` and `disabled` predicates `record.<block>` resolves, and `record.<block>.<leaf>` 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.<block>` 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 |
Expand Down
15 changes: 10 additions & 5 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading
Loading