Skip to content

Commit 9af0005

Browse files
feat(spec): ObjectSchema.attachedOnRead declares per-caller read attachments, and the shared validator judges record.BLOCK.LEAF against them (#22425)
Fixes #22386 Clause-②: yes (widening: a new optional `ObjectSchema` key) The spec half of #22211's ruling A (`6070963704`), built under the claim `6073245703` as amended by the seat order `6074312931`. **#22387 follows**: the plugin-approvals declaration of `viewer` on `sys_approval_request` and its conformance test. That card is blocked on this PR, and it is the one that finishes #22211; this PR leaves #22211 open, and its 8 shipped `record.viewer.*` predicates stay refused until #22387 declares the block. ## What changes - **`ObjectSchema.attachedOnRead`** (`packages/spec/src/data/object.zod.ts`, beside `fields`). An optional map from block name to that block's leaves, each naming its value type: `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. These are the blocks a service attaches to each row it serves, computed per caller and never stored. - Both key levels take the field-name grammar. A leaf type is one of `number | text | boolean | date`, taken from `FieldType` (the four `Field.returnType` value types). - **Refused at parse**, each located at the offending key: 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 declared field name. The last two live in the object's existing `superRefine`, so no new check node reaches the JSON Schema projection. - **Not a field.** No driver, form, list view, export, write path or translation bundle reads it. `git grep -c attachedOnRead` at the head finds it only in `packages/spec` (the schema, its pins, the ledger row, the generated surface), `packages/lint/src/validate-expressions*.ts`, `packages/formula/src/{validate,expression-refusal}*.ts`, `packages/metadata-core/src/object-schema-fls-references.ts` (classified `keep`), the generated reference docs and the changesets. - **Reader 1, the shared validator.** - `@objectstack/lint` `buildFieldIndex` adds each declared block name to the field-existence set, and both call sites that pass the field index hand the shared validator the block's leaves. - `@objectstack/formula` `checkFieldExistence` judges the SECOND segment of `record.BLOCK.LEAF` (and `previous.`) against the declared leaves, through the new optional `ExprSchemaHint.attachedOnRead`, under the EXISTING `unknown-field` code. No new rule id, no new refusal code, nothing keyed on `viewer`. - The refusal: `field` is the dotted path as written, `suggestion` the nearest declared leaf, and the new optional `block` + `leaves` params carry the declared leaves, present together exactly when the message names them: ``unknown field `viewer.can_actt` on `approval` (the read attachment `viewer` declares `can_act`, `is_submitter`) — did you mean `viewer.can_act`?`` - The head scan `RECORD_REF_RE` is untouched (the second segment is read by a separate sticky regex), and the judgement runs only for a declared block, so an object without the key takes exactly today's path. - **The leaf types' reader** is #22387's conformance test, which pins the keys `attachViewers` emits and the runtime type of each value against the declaration (seat order `6074312931`, recorded on #22387 as `6074324160`). The liveness row says so. - **Closed-world pins the new key moves** (seat order `6074312931`, Q1): the ADR-0106 D1 FLS position `attachedOnRead: keep`; the object form ledger's root `omit` row (the ruling says no form reads it); the composeStacks collection lists in the two pins, plus one row pinning that two stacks declaring different `attachedOnRead` for one object are refused under `objectConflict: 'merge'`. No `stack.zod.ts` change: the composition derives the set from the shape walk. - **Ledger and generated artifacts.** `liveness/object.json` grades the key `live` on an authoring consumer (the `action.execution` precedent), with `producer`, `evidenceScope` and `verifiedAt`. Generated by the repo's generators only: `authorable-surface/data.json` (build), `liveness/state-counts/object.md` (`gen:liveness-counts`), `content/docs/references/**` (`gen:docs`). - **Changesets.** `@objectstack/spec` minor (carries the Clause-② line); `@objectstack/formula` minor (additive public members); `@objectstack/lint` and `@objectstack/metadata-core` patch. ## Merge round at `a5104d854` After the contract review at `e19c1e349` (**PASS** `6077844912`; `Test Core (6/6)` was red only on #22415's timing-drift grade, which `main` reverted in #22435), `origin/main` was merged twice through `os-regen-merge.sh`, never rebased, with no conflict: - the first merge, up to `da159f74e`, brought the revert; - the second, up to `2b61f2d9d`, brought #22315, this card's named serial predecessor on `validate-expressions.ts`. The PR's delta is unchanged: `git diff origin/main...HEAD` is 22 files, +753 / −11, line for line. Measured at `a5104d854`, after `pnpm install --frozen-lockfile` and a full build (73 / 73): - lint 130 / 130 files, 5909 passed; - formula 45 / 45, 1278; - spec 630 / 630, 18791 + 1 todo; - gates: 111 derived, 111 run, 0 NOT-MEASURED, all exit 0. ## Merge round at `e19c1e349` After the contract review **PASS** `6076679507` at `a0545b853`, `origin/main` `05c7c3fa3` was merged through `os-regen-merge.sh`, never rebased. - **The one conflict:** the #5017 `PLUMBING` hunk in `validate-expressions.test.ts`. Both sides are kept: #22394's five names, then `attachedOnReadIndex`. `validate-expressions.ts` auto-merged. - **F4 fixed:** the form-ledger omit row's `why` now names the shared build validator. - **Measured at `e19c1e349`:** - lint 129 / 129 files, 5899 passed (#22394's 13 included); - formula 45 / 45, 1277; - spec 628 / 628, 18769 + 1 todo; - lint typecheck 0; - gates: 111 derived, 111 run, 0 NOT-MEASURED, all exit 0 after a full turbo build. - `metadata-protocol` and `metadata-core` are as below; the merge did not touch them. ## Measured at `a0545b853` The patch round after the contract review `6075426209` (FAIL on the sixth closed-world pin) merged `main` at `3054516ef` cleanly through `os-regen-merge.sh`. All runs went through `os-verify-lock` (each `VERDICT command-exit 0`; shared-box seconds), after the `metadata-protocol`, `lint` and `metadata-core` closures were built: | tier | result | |---|---| | `@objectstack/metadata-protocol` | 223 / 226 files (3 env-gated live-DB skips), 28325 passed, 19 skipped. `protocol.meta-types-degenerate-derivation.test.ts` passes. | | `@objectstack/spec` unit (`--project local`) | 628 / 628 files, 18769 passed, 1 todo | | `@objectstack/lint` | 129 / 129 files, 5886 passed | | `@objectstack/formula` | 45 / 45 files, 1277 passed | | `@objectstack/metadata-core` | 17 / 17 files, 421 passed | Typecheck exits 0 for `metadata-protocol` and `spec` at `a0545b853`. `lint`, `formula` and `metadata-core` exited 0 at `4ba858882`, and their sources are unchanged since. **Gates.** `dispatch-gates --commands` at `a0545b853` derived 111 commands: the previous 110 plus `check:durability-log-level`. All 111 exited 0 on the first pass, after a full `turbo run build`. `--ran` reconciles **111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN**. **The closed-world inventory of `ObjectSchema`'s top-level keys**, searched before the push. Every list or count found is visited: - `metadata-core` `OBJECT_REFERENCE_POSITIONS`; - the form-reconciliation ledger; - the two `composeStacks` pins; - `metadata-protocol` `CARD_PROPERTY_COUNTS.object` (44 → 45, this round); - `validate-expressions.test.ts`'s #5017 read surfaces; - the liveness row and its state counts; - the authorable surface and the reference docs. Derived sets needed no edit: the `composeStacks` shape walk, `ObjectSchema.create()`'s known keys, and the object form, where the key is an omit row. **Ablation: the misspelt-leaf pin bites.** `node scripts/ablation-replace.mjs` on `packages/formula/src/validate.ts`, anchor `if (leaves.includes(leaf)) return;` replaced by `return;` plus a marker: - the anchor went x1 to x0, and the blob `f7e24675` to `ac2f0590`; - `validate-attached-on-read.test.ts`: 3 failed, 6 passed. The misspelt-leaf, no-near-leaf and `previous`-root pins went red; the accept and control pins stayed green, which is the expected direction; - restore: blob == HEAD `f7e24675`, `git diff HEAD` empty, marker count 0; the restored run passed 9/9. The ablation ran at `37711ce70`. The file's blob at `4ba858882` is the same `f7e24675`. Formula's tests import `./validate` from source, so the ablation has no dist leg. ## Acceptance notes - **Doors that do not read the key, carried by #22387 (contract review `6075426209`, F1).** `packages/mcp`'s expression tool and `service-automation`'s flow-registration resolver build their own `record.*` field sets from `fields`. The wording here names only the shared build validator. #22387 threads the key into both, or shows that they are unreachable (`6075461181`). - **A block named like an injected system column is not refused at parse (F2), measured.** `object.zod.ts` cannot reach those names: `resolveInjectedSystemColumns` imports from `./object.zod`, which would make a cycle, and its column constants are module-private. Widening the refusal needs those constants exported below `object.zod.ts`. Carrier: none; the docblock records it. - **`ObjectExtensionSchema` cannot carry the key (F3).** That is consistent with an in-code declaration beside the service that attaches the block. - **Out of scope, carrier #22387 (noted, not filed).** This is the boundary of the ruling's literal "field-existence set". A declared block resolves at every surface the lint field index serves on that object: validation rules, hooks, field formulas, flow conditions. Yet only rows the declaring service serves carry the block. No producer writes such a predicate today, so this reaches nothing and is not a card. The field-formula pin in this PR covers the refusal half only. - **Where the judgement lives, measured.** `validate-predicate-path-refs.ts:55`–`:63` (#7010) scopes itself to the `data.*` layer and leaves `record.*` to the sibling rules. Judging there would be a new rule id, so the judgement lives in formula's `checkFieldExistence`, the existing `unknown-field` site. - **Name kept: `attachedOnRead`.** - The producer's own verb is "attach" (`attachViewers` / `attachDecisionProgress` / `attachFlowSteps`), and "OnRead" follows the spec's `enforceOnRead`. - The name has zero collisions repo-wide. - `readAttachments` would read as file attachments, and "virtual" or "computed" would read as fields. - **Unjudged, deliberately.** Index access (`record.viewer['x']`), a method call on a block, and a third segment are not judged. Each is a missed catch, never a false refusal. - **Merges.** `origin/main` was merged twice through `scripts/pm/os-regen-merge.sh`, never rebased. #22392 (#22274) landed on `validate-expressions.ts` in between; its three symbols are verified present at the head. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d87dff6 commit 9af0005

22 files changed

Lines changed: 753 additions & 11 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`ObjectSchema.attachedOnRead`: an object declares the blocks a service attaches to each row it serves, computed per caller on read and never stored
6+
7+
Clause-②: yes (widening: a new optional `ObjectSchema` key)
8+
9+
- **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.
10+
- **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.
11+
- **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.
12+
- **Nothing to migrate.** The key is optional and nothing writes it by default; an object without it parses and validates exactly as before.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/lint": patch
4+
"@objectstack/metadata-core": patch
5+
---
6+
7+
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`)
8+
9+
- **`@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.
10+
- **`@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.
11+
- **`@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.
12+
- **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.

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,7 @@ Metadata query with filtering, sorting, and pagination
948948
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
949949
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
950950
| **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. |
951+
| **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. |
951952
| **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
952953
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
953954
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ const result = ApiMethod.parse(data);
152152
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
153153
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
154154
| **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. |
155+
| **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. |
155156
| **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
156157
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
157158
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |

‎content/docs/references/system/migration.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ Create a new object
329329
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
330330
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
331331
| **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. |
332+
| **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. |
332333
| **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
333334
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
334335
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
@@ -616,6 +617,7 @@ Create a new object
616617
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
617618
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
618619
| **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. |
620+
| **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. |
619621
| **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
620622
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
621623
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |

‎packages/formula/src/expression-refusal-codes.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,26 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
235235
params: { field: 'zzz' },
236236
message: 'unknown field `zzz`',
237237
},
238+
{
239+
// A declared read attachment's second segment (#22386): the same code,
240+
// the dotted path, and the optional clause naming the declared leaves.
241+
produce: () =>
242+
onlyError('predicate', 'record.viewer.can_actt', {
243+
fields: ['status', 'viewer'],
244+
objectName: 'approval',
245+
attachedOnRead: { viewer: ['can_act', 'is_submitter'] },
246+
}),
247+
params: {
248+
field: 'viewer.can_actt',
249+
objectName: 'approval',
250+
suggestion: 'viewer.can_act',
251+
block: 'viewer',
252+
leaves: ['can_act', 'is_submitter'],
253+
},
254+
message:
255+
'unknown field `viewer.can_actt` on `approval` (the read attachment `viewer` declares `can_act`, `is_submitter`) ' +
256+
'— did you mean `viewer.can_act`?',
257+
},
238258
],
239259
'unknown-role': [
240260
{

‎packages/formula/src/expression-refusal.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,21 @@ export interface ExpressionRefusalParams {
9696
};
9797
/** The CEL engine refused a source that holds a template brace `{ref}`. */
9898
'cel-template-brace': { readonly role: CelFieldRole; readonly detail: string; readonly ref: string };
99-
/** `record.<field>` naming no field of the object. */
100-
'unknown-field': { readonly field: string; readonly objectName?: string; readonly suggestion?: string };
99+
/**
100+
* `record.<field>` naming no field of the object — or, for a declared read
101+
* attachment (`ExprSchemaHint.attachedOnRead`), `record.<block>.<leaf>`
102+
* naming no leaf of the block: then `field` is the dotted path as written
103+
* and `suggestion` the nearest declared leaf, also dotted. `block` and
104+
* `leaves` are present together, exactly when the message carries the clause
105+
* naming the leaves the block declares.
106+
*/
107+
'unknown-field': {
108+
readonly field: string;
109+
readonly objectName?: string;
110+
readonly suggestion?: string;
111+
readonly block?: string;
112+
readonly leaves?: readonly string[];
113+
};
101114
/** A role-membership test naming a role outside the catalog; `catalog` is every valid role. */
102115
'unknown-role': { readonly name: string; readonly suggestion?: string; readonly catalog: readonly string[] };
103116
/** `name.…` one typo away from a root the surface binds; `roots` are the surface's declared roots. */
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `ExprSchemaHint.attachedOnRead` — the second segment of
5+
* `record.<block>.<leaf>` judged against a declared read attachment (#22211
6+
* ruling A, #22386).
7+
*
8+
* A read attachment is a block a service sets on each row it serves, computed
9+
* per caller and never stored — plugin-approvals' `viewer: { can_act,
10+
* can_override, is_submitter }` on `sys_approval_request` is the worked case.
11+
* The block name resolves like a field (the caller lists it in `fields`); the
12+
* hint only adds the judgement of the segment after it, under the EXISTING
13+
* `unknown-field` code — no new code, and nothing keyed on the name `viewer`.
14+
*
15+
* Each verdict is asserted on its `code` and `params` (the named subject and
16+
* the remedy), the machine-readable half of the refusal.
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
21+
import { validateExpression, type ExprSchemaHint } from './validate';
22+
23+
const LEAVES = ['can_act', 'can_override', 'is_submitter'] as const;
24+
25+
/** An object that declares the `viewer` read attachment, as the lint field index hands it over. */
26+
const DECLARED: ExprSchemaHint = {
27+
objectName: 'sys_approval_request',
28+
fields: ['status', 'submitter_id', 'viewer'],
29+
attachedOnRead: { viewer: LEAVES },
30+
scope: 'record',
31+
};
32+
33+
/** The same object WITHOUT the key — today's hint, byte for byte. */
34+
const UNDECLARED: ExprSchemaHint = {
35+
objectName: 'sys_approval_request',
36+
fields: ['status', 'submitter_id'],
37+
scope: 'record',
38+
};
39+
40+
function refusalsOf(source: string, schema: ExprSchemaHint) {
41+
return validateExpression('predicate', source, schema).errors.map((e) => ({ code: e.code, params: e.params }));
42+
}
43+
44+
describe('ExprSchemaHint.attachedOnRead — record.<block>.<leaf>', () => {
45+
it('accepts a leaf the block declares', () => {
46+
for (const leaf of LEAVES) {
47+
const r = validateExpression('predicate', `record.viewer.${leaf} == true`, DECLARED);
48+
expect(r.errors, leaf).toEqual([]);
49+
expect(r.ok).toBe(true);
50+
}
51+
// The shipped shape: an OR of two leaves and a status read, one predicate.
52+
expect(
53+
validateExpression('predicate', "record.status == 'pending' && (record.viewer.can_act || record.viewer.can_override)", DECLARED).errors,
54+
).toEqual([]);
55+
});
56+
57+
it('refuses a misspelt leaf under the existing `unknown-field` code, naming the declared leaves', () => {
58+
expect(refusalsOf('record.viewer.can_actt == true', DECLARED)).toEqual([
59+
{
60+
code: 'unknown-field',
61+
params: {
62+
field: 'viewer.can_actt',
63+
objectName: 'sys_approval_request',
64+
suggestion: 'viewer.can_act',
65+
block: 'viewer',
66+
leaves: ['can_act', 'can_override', 'is_submitter'],
67+
},
68+
},
69+
]);
70+
});
71+
72+
it('refuses a leaf with no near declared spelling, still naming every declared leaf', () => {
73+
expect(refusalsOf('record.viewer.approved', DECLARED)).toEqual([
74+
{
75+
code: 'unknown-field',
76+
params: {
77+
field: 'viewer.approved',
78+
objectName: 'sys_approval_request',
79+
block: 'viewer',
80+
leaves: ['can_act', 'can_override', 'is_submitter'],
81+
},
82+
},
83+
]);
84+
});
85+
86+
it('judges the `previous` root the same way, and reports one leaf once', () => {
87+
expect(refusalsOf('previous.viewer.can_actt || record.viewer.can_actt', DECLARED)).toEqual([
88+
{
89+
code: 'unknown-field',
90+
params: {
91+
field: 'viewer.can_actt',
92+
objectName: 'sys_approval_request',
93+
suggestion: 'viewer.can_act',
94+
block: 'viewer',
95+
leaves: ['can_act', 'can_override', 'is_submitter'],
96+
},
97+
},
98+
]);
99+
});
100+
101+
it('refuses an undeclared block exactly as today — the FIRST segment, not the second', () => {
102+
expect(refusalsOf('record.viewr.can_act == true', DECLARED)).toEqual([
103+
{ code: 'unknown-field', params: { field: 'viewr', objectName: 'sys_approval_request', suggestion: 'viewer' } },
104+
]);
105+
});
106+
107+
it('an object without the key keeps today’s verdicts (control)', () => {
108+
// The block is unknown as a FIELD, exactly as before the key existed …
109+
expect(refusalsOf('record.viewer.can_act == true', UNDECLARED)).toEqual([
110+
{ code: 'unknown-field', params: { field: 'viewer', objectName: 'sys_approval_request' } },
111+
]);
112+
// … and a second segment off a known field is not judged at all.
113+
expect(refusalsOf('record.submitter_id.name == "x"', UNDECLARED)).toEqual([]);
114+
// Same expression, same verdict, with or without an EMPTY declaration.
115+
expect(refusalsOf('record.submitter_id.name == "x"', { ...UNDECLARED, attachedOnRead: {} })).toEqual([]);
116+
});
117+
118+
it('judges only a declared block: a second segment off any other field stays unjudged', () => {
119+
expect(refusalsOf('record.submitter_id.nmae == "x"', DECLARED)).toEqual([]);
120+
});
121+
122+
it('leaves a method call, an index read and a third segment unjudged — missed catches, never false refusals', () => {
123+
expect(refusalsOf("record.viewer['can_actt'] == true", DECLARED)).toEqual([]);
124+
expect(refusalsOf("record.viewer.split(',') == []", DECLARED)).toEqual([]);
125+
expect(refusalsOf('record.viewer.can_act.foo == true', DECLARED)).toEqual([]);
126+
});
127+
128+
it('reads the declaration by OWN key only — an inherited name is never a block', () => {
129+
const hint: ExprSchemaHint = { ...DECLARED, fields: [...(DECLARED.fields ?? []), 'constructor'] };
130+
expect(refusalsOf('record.constructor.name == "x"', hint)).toEqual([]);
131+
});
132+
});

0 commit comments

Comments
 (0)