Skip to content

Commit 482e089

Browse files
fix(lint)!: the remaining field-name indexes judge an object as served, so a read or write of a field an objectExtensions entry adds is not refused (#30471) (#30533)
domain:spec#3 · os-support-ai · session_01WGufzft7TrxiryiTZm4o9p <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBUnyh7Bt4mU16ZEEJTzLZHv)_ Fixes #30471 Clause-②: yes (narrowing) — `os validate` / `os build` / `os lint` refuse three shapes they accepted before: a hook body's literal `ctx.api.object(NAME).update(…)` / `.updateById(…)` / `.insert(…)` writing a field an `objectExtensions` entry adds and declares `readonly: true` moves from a `hook-body-write-unknown-field` warning to a `hook-api-update-readonly-field` error; and a translation key naming a field or validation rule that only a replaced earlier entry of the same package adds, or only a top-level `objectExtensions` beside `packages[]` adds, is now `translation-target-unknown` at `error`, where it resolved, and with that top-level-beside shape no longer read, a translation subtree `objects.X` keyed to an object the stack does not define and only such an entry extends loses the extended-object skip (rung 2b, `validate-translation-references.ts` :1859) and is `translation-target-unknown` at `error` too; and a field an `objectExtensions` entry RE-DECLARES on its owner's object is judged on the extension's definition, which replaces the owner's whole in the fold as in the registry's `mergeObjectDefinitions` (`{ ...base.fields, ...extension.fields }`), so an owner `amount: number` re-declared as `formula`, `readonly: true` or `text` now gets that definition's `sort-field-unsortable`, readonly-write or expression type-mismatch verdict, each at `error` (ADR-0146 D10 already refuses that shape on the extension; no CLI door does). The widening half: a formula, validation, flow condition, hook condition, sharing-rule condition, flow-node, action-body or hook-body write, react page field prop, translation key, page sort or list-view sort naming a field an `objectExtensions` entry of the same artifact adds is no longer refused as an unknown field. Metadata-protocol: narrowed Skills-docs: updated here — content/docs/automation/hook-bodies.mdx (`:220`, the write rules' declarations include extension fields; `:312`, the same for `hook-api-update-readonly-field`). Measured at `7ca63171`: `git grep` of the moved ids (the three unknown-field write ids, the five readonly ids, `sort-field-unknown`, `sort-field-unsortable`, `page-field-unknown`) over `skills/` and `content/docs/` (releases and references excluded) hits 11 lines in 3 files; `content/docs/automation/hooks.mdx:43-47` and `content/docs/deployment/validating-metadata.mdx:177` state the rules without a declared-only claim ("a field the bound object lacks") and read true; no skill names them. Prior rulings read: objectextensions,buildfieldindex,indexobjectfields,buildreadonlyindex,indexobjectsortability → 2 hits; ADR-0029 D3, ADR-0146 D10; thread: not read (no --card); repo: objectstack-ai/objectstack ## 维护者速读 - 改了什么:lint 里剩下的字段名索引(表达式检查、流程节点/动作体/钩子体三条写入规则、React 页面字段属性、翻译键、页面与列表视图排序、三条只读写入规则)都改读同一份“按注册表实际提供的对象”折叠:声明字段,加上同一制品里任何包通过 `objectExtensions` 加的字段。扩展字段不再被报成未知字段,它的定义(只读、能否排序、类型)按声明字段一样判断。 - 为什么改:HotCRM 这类多包制品里,模块包给 app 包的对象加了字段,lint 却把读、写、排序这个字段一律报“未知字段”,而运行时明明提供它;扩展字段真正的问题(写只读字段会被运行时静默丢掉、按公式字段排序每次都 400)也被“未知字段”盖住了。 - 风险与回滚:这是收窄,`os validate` / `os build` / `os lint` 会拒绝三类以前放行的制品:① 钩子体用 `ctx.api` 写一个扩展加的 `readonly: true` 字段,从警告(`hook-body-write-unknown-field`)升为错误(`hook-api-update-readonly-field`),因为运行时会静默剥掉这次写入;② 翻译键指向的字段或校验规则只出现在同一包被后一条替换掉的旧扩展条目里,或只出现在与 `packages[]` 并列的顶层 `objectExtensions` 里,现在报 `translation-target-unknown` 错误(注册表不提供它);同理,翻译里 `objects.X` 指向本制品没定义、只被这种顶层条目扩展的对象,不再按“被扩展的对象”跳过,也报这个错误;③ 扩展条目重新声明 owner 对象上已有的字段(如 owner 的 `amount: number` 被改成 `formula` / `readonly: true` / `text`)时,按扩展的定义判断,与注册表 `mergeObjectDefinitions` 整个替换字段一致,于是排序报 `sort-field-unsortable`、流程或钩子写入报只读错误、校验规则里乘法报类型不符,均为错误(ADR-0146 D10 在注册时就拒绝这种写法,CLI 此前没有任何一道门拦它)。其余变化只是换规则编号且同级或降级:流程节点写只读扩展字段 错误→错误(`flow-update-readonly-field`),页面和列表视图按公式扩展字段排序 错误→错误(`sort-field-unsortable`),三处 `readonlyWhen` 写入为警告。两个包在同一优先级写同一字段时不判它的定义;一个字段都没声明的对象照旧不判。回滚 = revert 本 PR。 - 先看:`packages/lint/src/validate-readonly-flow-writes.ts` 的 `buildServedReadonlyIndex`(三条只读规则共用,含“对象一个字段都没声明就不判”);`packages/lint/src/validate-sortable-fields.ts` 的 `indexServedSortFields`;`packages/lint/src/object-graph.test.ts` 末尾 `[#30471]` 里 “each refusal this change adds” 一组。 - 两分钟验证:运行 `pnpm --filter @objectstack/lint exec vitest run src/object-graph.test.ts -t 30471`,预期 69 例全绿;其中 “each refusal this change adds” 的 9 行各自断言新的规则编号与级别,并断言只看声明字段时(即 main 的视角)仍报原来的未知字段;另 2 行(重新声明 owner 字段:列表视图按 formula 排序、流程写 readonly)断言新的错误,只看声明字段时什么也不报。 - 需要你做的:无,席位落地。 ## What changes - **`validate-expressions.ts`**: the field-name index (`buildFieldIndex`) is built over `objectsAsServed(stack)`; the field-type and nullable indexes over `objectsWithoutTiedFields`. The walk stays on the declared `objects`, so every finding keeps its declared path and an extension's own formula or predicate is not walked. The read-attachment index is object-level and stays on the declared objects. - **`validate-hook-body-writes.ts` `indexObjectFields`** (read by the flow-node, action-body and hook-body write rules): the served field names; an object declaring no field keeps its empty set, so `judgeableFieldsOf` still leaves it unjudged when an extension adds to it. A tied name is kept (existence only). - **`validate-react-page-props.ts`**: indexes the served objects, as `validatePageFieldBindings` does since #30383. - **`validate-translation-references.ts`**: its private `objectExtensionsByTarget` is deleted; the universe reads the shared fold, which now carries each entry's `validations` (`object-graph.ts`, `VisibleObjectExtension.validations`). - **The three readonly write rules** share a new `buildServedReadonlyIndex` (`validate-readonly-flow-writes.ts`): `buildReadonlyIndex` over `objectsWithoutTiedFields(objectsAsServed(stack))`, an object that declares no field keeping its own empty map. Without that index, moving the existence question would have left a write to a readonly extension field silent; without the empty-map guard, an extension adding a readonly field to an object that declares none made the readonly rules refuse writes `main` left silent (measured at `cc67f35a`: flow and hook at `error`, the three `readonlyWhen` ids at `warning`). - **`validate-sortable-fields.ts`** (open to this card since #30270 landed at `c045c341`): `indexServedSortFields` builds the existence index (`indexObjectSearchTargets`' shape) over the served objects, a tied name kept, and the sortability projection (`indexObjectSortability`) over `objectsWithoutTiedFields`; an object that declares no field map keeps its declared answer on both (skip 2). `validate-searchable-fields.ts` is not touched; the unprovisioned-anchor index is object-level and stays on the declared objects. - **`rule-explanations.ts`** and **`content/docs/automation/hook-bodies.mdx`**: "declared" includes the fields an `objectExtensions` entry of the same artifact adds (`DECLARED_FIELD_DOOR`, `page-field-unknown`, `translation-target-unknown`; `hook-bodies.mdx:220`, `:312`). - **Tests**: the `[#30471]` describe in `object-graph.test.ts` (the fold's harness), 69 cases; see the table and Verification. - **Docblocks** (no behaviour change): `objectsAsServed` states the one-to-one, in-order contract `indexObjectFields` joins on (`objects[i]` is the served form of `recordsOf(stack.objects)[i]`, early return included); `validate-react-page-props.ts` at the served index and the write index's docblock say that handing pre-served objects bypasses the declares-nothing guard by construction, and why no verdict moves (the react rule judged that empty set on `main` too). - `.changeset/30471-field-name-indexes-extension-fields.md`: `@objectstack/lint` **minor**, BREAKING banner, ADR-0087 disposition `not-required (no-migration-prescription)`. ### Per site One artifact: an app package declares `xs_deal` (`name`, `amount`) and `xs_account`; a module package extends `xs_deal`. | Site | Moved or kept | Why | Pin | | --- | --- | --- | --- | | `validate-expressions.ts` `buildFieldIndex` (validations, formulas, flow and hook conditions, sharing-rule conditions) | moved | the runtime evaluates `record` against the served row | an extension field read raises nothing; refused without the extension; `nowhere` byte-for-byte; per-package leg same | | `validate-expressions.ts` field-type and nullable indexes | moved, ties dropped | type and nullability are the served definition's; a tie's winner is registration order | tie (either order, both legs) raises nothing; the single-writer controls raise the type mismatch and the null guard | | `validate-expressions.ts` read-attachment index, hop walk | kept on declared | object-level; the walk carries declared paths | n/a | | `indexObjectFields` (hook-body-writes) for `flow-node-write-unknown-field`, `action-body-write-unknown-field`, `hook-body-write-unknown-field` | moved | the write lands on the served object | each: passes, refused without, `nowhere` byte-for-byte, per-package leg; an object declaring no field stays unjudged | | `validate-react-page-props.ts` `columns` (page-field-bindings' `indexObjectFields`) | moved | the renderer binds the served object | passes, refused without, byte-for-byte, per-package leg | | `validate-react-page-props.ts` `searchableFields` (`indexObjectSearchTargets`) | kept on declared | its type question is the searchable-fields rule's, carried | still `searchable-field-unknown` on an extension field | | `validate-page-field-bindings.ts` `indexObjectFields` | nothing to move | both callers hand it served objects (`:640-641` since #30383, react props `:1144`); #30270 added no declared-only reader | covered by #30383's pins and the react row | | `validate-translation-references.ts` private fold | retired onto the shared fold (carries `validations`) | one fold; the registry's one entry per package per target, `packages[]` alone when present | `fields.region`, `_validations.region_set` resolve; refused without; byte-for-byte; per-package leg; the two refusals the fold adds pinned at `error` | | readonly flow, hook and action rules (`buildReadonlyIndex`) | moved, ties dropped, declares-nothing kept | readonly is the served definition's; an object with no own field is an introspected schema | the refusal table (9 rows × 3 legs) plus the per-rule describe | | `validate-sortable-fields.ts` existence (`indexObjectSearchTargets`) | moved, tie name kept, declares-nothing kept | the list door judges the served schema; measured on the merged tree: a page sort naming an extension field was `page-field-unknown` `error` on both CLI legs | page sort and list-view sort rows in the existence table (passes, refused without, byte-for-byte, per-package leg) | | `validate-sortable-fields.ts` sortability (`indexObjectSortability`) | moved, ties dropped, declares-nothing kept | without it a sort by an extension formula field would turn silent where the door answers `400 INVALID_SORT` | refusal table rows (formula → `sort-field-unsortable`, both legs, tie, declares-nothing) | ### Every finding that moves, against `main` Measured with `runAuthoringRules('validate', …)` over the built dist of `origin/main` at `c23eafa8` and of this head, on the union artifact and the per-package leg (identical on both); the field is one an `objectExtensions` entry adds to an object that declares fields of its own. The last four rows were measured in round 2 over the built dist of `origin/main` at `5b748dfe` and of this head; the no-extension control is identical on both trees, and the re-declared shapes not listed (a `formula` or `text` re-declaration written, a `readonly` one sorted or read, an action's `ctx.api` write) move nothing. | Write or read of the extension field | `main` | this head | | --- | --- | --- | | hook `ctx.api` update / updateById / insert, `readonly: true` | `hook-body-write-unknown-field` warning | **`hook-api-update-readonly-field` error** | | translation key naming only a replaced same-package entry's field or rule, or only a top-level `objectExtensions` beside `packages[]` | resolves | **`translation-target-unknown` error** | | flow `update_record` / `create_record`, `readonly: true` | `flow-node-write-unknown-field` error | `flow-update-readonly-field` error | | page sort (every slot) / list-view sort, `formula` | `page-field-unknown` / `sort-field-unknown` error | `sort-field-unsortable` error | | flow `update_record`, `readonlyWhen` | `flow-node-write-unknown-field` error | `flow-update-readonly-when-field` warning | | hook `ctx.api` update, `readonlyWhen` | `hook-body-write-unknown-field` warning | `hook-api-update-readonly-when-field` warning | | action `ctx.api` update, `readonlyWhen` | `action-body-write-unknown-field` warning | `action-api-update-readonly-when-field` warning | | expression with a type or null-guard problem | unknown-field error | the type mismatch / null guard: error in validations and hook conditions, warning in formulas and sharing-rule conditions | | any other read, write or sort (a hook `ctx.input` stamp, an action `ctx.api` write of a `readonly: true` field, a `create_record` of a `readonlyWhen` field, a react `columns` prop, a plain sort) | unknown-field error or warning | nothing | | the same on an object that declares no field of its own | nothing | nothing | | an OWNER field an extension re-declares (owner `amount: number`), sorted on a page (`interfaceConfig.sort`) or list view, re-declared `formula` | nothing | **`sort-field-unsortable` error** | | the same owner field re-declared `readonly: true`, written by a flow `update_record` or a hook `ctx.api` update | nothing | **`flow-update-readonly-field` / `hook-api-update-readonly-field` error** | | the same owner field re-declared `text`, multiplied in a validation rule (`record.amount * 2 > 10`) | nothing | **`expression-invalid` error (type mismatch `string * int`)** | | a translation subtree `objects.X` keyed to an object the stack does not define, extended only by a top-level `objectExtensions` beside `packages[]` | skipped (rung 2b) | **`translation-target-unknown` error** | ## Why The runtime serves each object with every body's `objectExtensions` folded in. After #30383 the field-path rules read that served shape, but these field-name indexes still read declared objects only, so on a multi-package artifact (HotCRM, hotcrm#2315) a formula, validation, flow, hook, write, page prop, sort or translation key naming an extension-added field was refused as unknown, and the extension field's real problem (a readonly strip, an unsortable formula) went unjudged. ## Decisions - **Translation references.** The shared fold carries each entry's `validations` and the private fold is retired. Carrying one more key changes nothing about which entries survive. The two measured differences from the retired reader are the registry's reading and are refusals at `error` (declared above and pinned): a key naming a field or rule only a replaced earlier entry of the same package adds, and a top-level `objectExtensions` beside `packages[]` (a shape no CLI leg produces; with no `packages[]` the top level is read). - **The declares-nothing skip** is kept on the readonly and sort rules as on the write rules: an object that declares no field is an introspected schema, and an extension adding to it does not make it judgeable. The contract review read the readonly rules' `fieldMap.size === 0` guard as keeping this; on the served index it no longer fired, so `buildServedReadonlyIndex` restores it. - **`defineStack`'s `collectRelatedListNameErrors` stays additive.** Aligning it with the registry's replacement measured as a further narrowing (two shapes accepted today would be refused `STACK_CROSS_REFERENCE_INVALID`), outside this card's declaration; agreed with the seat. ## Acceptance notes - **Census beyond the card** (by reading; not changed here, carried to the seat): field indexes still on `recordsOf(stack.objects)`: `validate-form-layout.ts` (map-shaped `fields` only), `validate-notify-email-field-recipients.ts` and `validate-public-form-file-fields.ts` (field types), `validate-flow-template-paths.ts` (field types), `validate-page-visualization-bindings.ts`, `validate-searchable-fields.ts` `indexObjectSearchTargets` (also behind a react page's `searchableFields`, pinned as still refused), and `lint-autonumber-formats.ts` `:56-63` (`fieldMeta` from the object's own fields, so an owner's autonumber format naming a field a sibling adds reads as unresolvable). - **Observed on `main`, not changed here:** an object that declares no field, once an `objectExtensions` entry adds to it, gets `list-view-field-unknown` at `error` on a list-view column the extension does not add (the #30383 graph reads the served object as the extension's fields alone); unextended, the same view is skipped. Reported to the seat with the reading. - **Ties inside the existence rules** (writes, page props, sorts) accept the name, as #30383's existence rules do; only the definition questions drop a tie. - **The nullable index drops a tie even when every tied definition is nullable.** Before this PR an extension field had no null-guard verdict at all, so nothing is lost against `main`. - **Title.** The changeset is BREAKING and its summary reads `fix(lint)!:`; the seat added the `!` to the PR title with this body (precedent #30270). - **The page publish door.** `validateSortableFields` also runs at `PUT /api/v1/meta/page/NAME`; this PR measured the CLI legs only. ## Verification Round 2, at head `80c7ae3a` (this branch with `origin/main` at `5b748dfe` merged in `fed33b86`, clean; the merge brings `packages/lint` changes, so the dogfood battery was run again), after `turbo run build --filter='!@objectstack/docs' --concurrency=2` under `os-verify-lock` (118/118 tasks, VERDICT command-exit 0), `NODE_OPTIONS=--max-old-space-size=4096`. - `pnpm --filter @objectstack/lint test && pnpm --filter @objectstack/lint typecheck` under `os-verify-lock`: Test Files 195 passed (195), Tests 8172 passed (8172); `check:test-typecheck: OK`; VERDICT command-exit 0. The `[#30471]` describe: 69 passed. - Full dogfood battery, `pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 0; Test Files 420 passed, 3 skipped (423); Tests 3496 passed, 36 skipped (3532). - `npx eslint` over the 12 changed `.ts` files: exit 0. Round 1, at head `7ca63171` (`origin/main` at `c23eafa8` merged in `cc67f35a`), 119/119 build tasks: - `pnpm --filter @objectstack/lint test && pnpm --filter @objectstack/lint typecheck` under `os-verify-lock`: Test Files 195 passed (195), Tests 8158 passed (8158); `check:test-typecheck: OK`; VERDICT command-exit 0. The `[#30471]` describe: 67 passed. - Consumers of `@objectstack/lint` whose tests run the rule registry or the moved rules on fixtures, each under the lock, exit 0: `@objectstack/cli` (`--project unit`; the integration layer is CI's) 389 files / 5430 tests; `@objectstack/metadata-protocol` 317 passed, 4 skipped / 22442 passed, 26 skipped; `@objectstack/mcp` 56 / 678; `@objectstack/platform-objects` 112 / 1528; `@objectstack/plugin-approvals` 111 / 1469; `@objectstack/cloud-connection` 70 / 877; `@objectstack/service-mailbox` 10 / 152; `@objectstack/example-showcase` 46 / 544; `@objectstack/objectql` 552 / 11005; `@objectstack/runtime` 543 / 8655 passed, 19 skipped. - Full dogfood battery (the `(narrowing)` arm owes it), `pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 1; Test Files 416 passed, 4 failed, 3 skipped (423); Tests 3482 passed, 3 failed, 47 skipped (3532). Every failure is a timeout, none an assertion: `Hook timed out in 10000ms` in `package-first-authoring.dogfood.test.ts` (and its three later suites refused with the `already has a live bootStack kernel` cascade a timed-out hook leaves) and in `sys-file-metadata-write-refusal.dogfood.test.ts`; `Test timed out in 5000ms` in `activity-withheld-update.dogfood.test.ts` (1) and `analytics-engine-middleware-objects.dogfood.test.ts` (2). The four files re-run alone under the lock at load average 7.8: Test Files 4 passed (4), Tests 23 passed (23), VERDICT command-exit 0. None of them reaches a moved rule's verdict. - `npx eslint --no-inline-config` over the 12 changed `.ts` files: exit 0. <details><summary>Before/after against main (the table above)</summary> - Two throwaway probes (scratch, outside the repo) call `runAuthoringRules('validate', …)` from each tree's built `dist` (`origin/main` at `c23eafa8` in a second worktree, this head) on the same stacks: an app body declaring `xs_deal` and `xs_account`, an object declaring no field (`xs_empty`), and a module body extending both with a `readonly: true` text field, a `readonlyWhen` number field, a plain text field and a `formula` field; each write surface (flow `update_record` / `create_record`, action `ctx.api` update / insert, hook `ctx.api` update / insert, hook `ctx.input`), each sort surface (`interfaceConfig.sort`, an element `dataSource.sort`, a `defineView` list), the expression passes (validation, formula, sharing rule, hook condition) with a text read, text arithmetic, an unguarded number compare and a date compare, a react `columns` prop and translation keys on the replaced-entry and top-level-beside shapes; on the union artifact, the per-package leg and a no-extension control. - At the merged head before this round's code (`cc67f35a`), the readonly rules refused writes to `xs_empty` that `main` left silent: `flow-update-readonly-field` and `hook-api-update-readonly-field` at `error`, the three `readonlyWhen` ids at `warning`, on both legs. At this head no `xs_empty` case differs from `main`. - Every remaining difference is a row of the table above; the no-extension control is identical on both trees for every case. - Round 2: a third probe, the same way over `origin/main` at `5b748dfe` and this head, re-declares the owner's `amount: number` in the module body as `formula`, as `readonly: true` and as `text`, against a page and a list-view sort, a flow `update_record`, a hook and an action `ctx.api` update and a validation rule `record.amount * 2 > 10`, plus a translation subtree keyed to an undefined object extended by a top-level entry beside `packages[]` and by a package body. The differences are the four new rows of the table; the no-extension control and the package-body translation control are identical on both trees. </details> <details><summary>Ablations (each through scripts/ablation-replace.mjs: anchor hit once, the describe run, restored, blob equal to HEAD, git diff HEAD empty)</summary> The lint tests import `src` (relative imports, no alias to `dist`), so no build sits between the mutation and the run. - `indexServedSortFields`: existence over `stack` instead of the served objects → **8 failed** / 59 passed (the two sortable existence rows on both legs, the sort rows of the refusal table and their tie legs). - sortability over `stack` → **4 failed** (the two sort refusal rows and their tie legs); sortability over the served objects with ties kept → **2 failed** (the two sort tie legs); the declares-nothing loop skipped → **2 failed** (the two sort declares-nothing legs). - `buildServedReadonlyIndex`: the empty-map guard removed → **7 failed** (the seven readonly declares-nothing legs); ties kept → **10 failed** (every readonly tie leg, old and new). - each readonly rule set back to the declared index: hook → **8 failed**, flow → **8 failed**, action → **4 failed** (that rule's refusal rows, tie legs and the per-rule describe rows). - `objectExtensionsByTarget` reading the top level beside `packages[]` → **1 failed** (the new translation pin); keeping every entry of a package → **2 failed** (the replaced-entry pin and the new one). - Round 2, `objectsAsServed` no longer replacing an owner's field an extension re-declares (the fold keeps the owner's definition) → **2 failed** / 67 passed (the two re-declared rows), restored, blob `6d6f1b21` equal to HEAD. </details> <details><summary>Gates</summary> - Round 2, at `80c7ae3a`: `dispatch-gates --commands` derives the same 88 commands, each run as printed with its exit code recorded. First pass: 87 exit 0 (the teardown fixture fetched again first) and `pnpm check:type-check-debt` exit 3 (PREREQUISITE NOT MET: its closure build timed out queueing behind a sibling's locked build); re-run under `os-verify-lock` it exits 0 (0 raw tsc errors, none above its recorded number). `dispatch-gates --ran`: "88 derived, 88 run, 0 NOT-MEASURED", exit 0. `check-changeset-no-major --base origin/main` with this body as the event: exit 0, the same LEVEL AXIS line. `check-adr-0087-registration --base origin/main`: exit 0, one declared-breaking changeset `[BREAKING+bang+clause-②-narrowing+metadata-protocol-narrowed]`, `not-required (no-migration-prescription)`. `check-empty-changeset --base origin/main`: exit 0. - Round 1, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `7ca63171`: 88 derived commands, each run as printed with its exit code recorded beside it. First pass: 87 exit 0 and `check-plugin-teardown-shape.mjs --self-test` exit 3 (PREREQUISITE NOT MET: its pinned fixture commit `621a4876` was outside the shallow clone); after `git fetch origin 621a487…:refs/issue-30471/teardown-fixture` it exits 0 (48 cases). - `dispatch-gates --ran`: "88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. - `node scripts/check-changeset-no-major.mjs --base origin/main --event EVENT` with this body as the `pull_request` payload: exit 0, "LEVEL AXIS: this PR declares clause-② `yes (narrowing)` and metadata-protocol `narrowed`, and no package … is graded `patch`". - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0, one declared-breaking changeset, `not-required (no-migration-prescription)`. - `node scripts/check-empty-changeset.mjs --base origin/main`: exit 0. - CI at `7ca63171` before this body: every check-run concluded, all `success` or `skipped` except `Check Changeset`, `failure` on the previous body's `Metadata-protocol: none` against the `minor` changeset (the reading this body replaces). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2046d21 commit 482e089

14 files changed

Lines changed: 579 additions & 93 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: the field-name indexes judge an object as the registry serves it, so a read, write or sort naming a field an `objectExtensions` entry adds is no longer refused as unknown, and the readonly, sortability and translation rules judge that field by its served definition (#30471)
6+
7+
Clause-②: yes (narrowing) — `os validate` / `os build` / `os lint` refuse three shapes they accepted before: a hook body's literal `ctx.api.object(NAME).update(…)` / `.updateById(…)` / `.insert(…)` writing a field an `objectExtensions` entry adds and declares `readonly: true` moves from a `hook-body-write-unknown-field` warning to a `hook-api-update-readonly-field` error; and a translation key naming a field or validation rule that only a replaced earlier entry of the same package adds, or only a top-level `objectExtensions` beside `packages[]` adds, is now `translation-target-unknown` at `error`, where it resolved, and with that top-level-beside shape no longer read, a translation subtree `objects.X` keyed to an object the stack does not define and only such an entry extends loses the extended-object skip (rung 2b) and is `translation-target-unknown` at `error` too; and a field an `objectExtensions` entry RE-DECLARES on its owner's object is judged on the extension's definition, which replaces the owner's whole in the fold as in the registry's `mergeObjectDefinitions` (`{ ...base.fields, ...extension.fields }`), so an owner `amount: number` re-declared as `formula`, `readonly: true` or `text` now gets that definition's `sort-field-unsortable`, readonly-write or expression type-mismatch verdict, each at `error` (ADR-0146 D10 already refuses that shape on the extension; no CLI door does). The widening half: a formula, validation, flow condition, hook condition, sharing-rule condition, flow-node, action-body or hook-body write, react page field prop, translation key, page sort or list-view sort naming a field an `objectExtensions` entry of the same artifact adds is no longer refused as an unknown field.
8+
Metadata-protocol: narrowed
9+
Skills-docs: updated here — content/docs/automation/hook-bodies.mdx
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: objects, object extensions, flows, hooks, actions, pages, views and translation bundles keep their shapes and meanings. What moves is which rule judges a name an objectExtensions entry adds, and at what severity: the hook write it now refuses was dropped at run time by the readonly strip, and the translation key it now refuses names nothing the registry serves. There is no rewrite that keeps what the author meant; the author drops the write or the key, or moves it to the entry the registry keeps, and the refusal says which. -->
12+
13+
**BREAKING** accept-set narrowing in `os validate` / `os build` / `os lint` (`@objectstack/lint`), for multi-package artifacts whose bodies add fields to another package's object through `objectExtensions`.
14+
15+
- **Before.** The field-name indexes of the expression passes (validation rules, formulas, flow and hook conditions, sharing-rule conditions), the three write rules, a react page's field props, the three readonly write rules and the sort rule read an object's declared `fields` only, so a name an `objectExtensions` entry adds was refused as unknown although the runtime serves the field: `flow-node-write-unknown-field` and `sort-field-unknown` / `page-field-unknown` (page sorts) at `error`, `action-body-write-unknown-field`, `hook-body-write-unknown-field` and `page-field-unknown` (react props) at `warning`, the expression passes' unknown-field error. The translation rule folded extensions through a reader of its own that kept every entry and also read a top-level `objectExtensions` beside `packages[]`.
16+
- **Now.** Every one of them reads the one shared fold in `object-graph.ts` (since #30383 the field-path rules' fold): the declared fields plus each field an `objectExtensions` entry adds, one entry per package per object, `packages[]` alone when the artifact carries it. A name no body declares or adds keeps the same refusal, rule id, severity and message; a misspelling within edit distance 3 of an extension-added name now gets a did-you-mean naming it. A name the fold adds is judged by its served definition, so these findings move (union artifact and per-package leg alike):
17+
- a hook body's `ctx.api` update / updateById / insert of a `readonly: true` field: `hook-body-write-unknown-field` `warning` → `hook-api-update-readonly-field` `error`, the severity a declared readonly field already gets (a non-system hook's `ctx.api` write is stripped at run time and the call returns success);
18+
- a translation key naming a field or rule only a replaced earlier entry of the same package, or only a top-level `objectExtensions` beside `packages[]`, adds: resolved → `translation-target-unknown` `error`, as the registry serves the object;
19+
- a translation subtree `objects.X` keyed to an object the stack does not define and only a top-level `objectExtensions` beside `packages[]` extends: skipped as an extended object (rung 2b) → `translation-target-unknown` `error`;
20+
- an owner's field an `objectExtensions` entry re-declares is judged on the extension's definition, which replaces the owner's whole as the registry's `mergeObjectDefinitions` does (`{ ...base.fields, ...extension.fields }`): an owner `amount: number` re-declared as `formula` and sorted (page or list view) → `sort-field-unsortable` `error`; re-declared `readonly: true` and written by a flow `update_record` or a hook's `ctx.api` update → `flow-update-readonly-field` / `hook-api-update-readonly-field` `error`; re-declared `text` and multiplied in a validation rule → the expression type mismatch at `error`. Each passed before, the owner's definition being the one judged. ADR-0146 D10 refuses this shape on the extension at registration; no CLI door does;
21+
- a flow `update_record` / `create_record` of a `readonly: true` field: `flow-node-write-unknown-field` `error` → `flow-update-readonly-field` `error`;
22+
- a page sort (every slot) or list-view sort by a `formula` field or one declaring `sortable: false`: `page-field-unknown` / `sort-field-unknown` `error` → `sort-field-unsortable` `error`;
23+
- a `readonlyWhen` field written by a flow `update_record` (`error` → `flow-update-readonly-when-field` `warning`), a hook's `ctx.api` update (`warning` → `hook-api-update-readonly-when-field` `warning`) or an action's `ctx.api` update (`warning` → `action-api-update-readonly-when-field` `warning`);
24+
- an expression reading the field: the unknown-field error becomes the served type's verdict (a type mismatch or a missing null guard, at `error` in validation rules and hook conditions, at `warning` in formulas and sharing-rule conditions) or nothing.
25+
Every other read, write or sort naming such a field passes: a hook's `ctx.input` stamp, an action's `ctx.api` write of a `readonly: true` field (an action runs elevated) and a `create_record` of a `readonlyWhen` field land at run time.
26+
- **Not judged.** A field two packages write at the same deciding priority is a served name, but no rule judges its type, nullability, readonly or sortability: which definition wins is registration order. An object that declares no field of its own stays unjudged by the write, readonly and sort rules, extended or not (an introspected schema). A react page's `searchableFields` still reads declared fields only.
27+
- **Not changed.** Which extension entries the fold keeps, the runtime, and `defineStack` (its related-list check stays additive). A stack that names no extension field reads as before.
28+
29+
**What an author does.** For a hook writing a readonly extension field, declare the hook `runAs: 'system'`, stamp the value in the record's own `beforeInsert` / `beforeUpdate` hook, or drop the field from the payload. For a translation key the fold drops, key it to a field or rule of the entry the registry keeps (the package's last entry for that object, in a `packages[]` body), or delete it.

‎content/docs/automation/hook-bodies.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ Static validation around a hook is asymmetric, and it is worth knowing exactly w
217217

218218
- **Checked — read side.** `hook.condition` is validated at build time against the target object's fields by the expression validator (`@objectstack/lint`), including array-valued `hook.object` targets. A condition referencing a nonexistent field fails the lint.
219219
- **Checked — capability side.** `body.capabilities` gates which `ctx` APIs the body may call at all; the sandbox throws on an undeclared call.
220-
- **Checked — write side, literal-only.** Since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271), `body.source` is **parsed** (never executed, never type-checked) and the field names it writes are resolved against the target object's declarations. An unknown field raises `hook-body-write-unknown-field` — a **warning** carrying a did-you-mean suggestion, which never blocks a build. Action bodies get the same check on their `ctx.api` writes (`action-body-write-unknown-field`). Both run under `os validate`, `os lint` and `os compile`. The *existence* question is advisory like this because the answer can depend on a package the build cannot see; the separate *writability* question — [writing a `readonly` field through `ctx.api`](#writing-a-readonly-field) — **does** gate, because both halves of that judgement are declared in the stack being checked.
220+
- **Checked — write side, literal-only.** Since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271), `body.source` is **parsed** (never executed, never type-checked) and the field names it writes are resolved against the target object's declarations, which include every field an `objectExtensions` entry of the same artifact adds to it. An unknown field raises `hook-body-write-unknown-field` — a **warning** carrying a did-you-mean suggestion, which never blocks a build. Action bodies get the same check on their `ctx.api` writes (`action-body-write-unknown-field`). Both run under `os validate`, `os lint` and `os compile`. The *existence* question is advisory like this because the answer can depend on a package the build cannot see; the separate *writability* question — [writing a `readonly` field through `ctx.api`](#writing-a-readonly-field) — **does** gate, because both halves of that judgement are declared in the stack being checked.
221221
- **Checked — writes to a system column the object has no storage for.** Since [#8663](https://github.com/objectstack-ai/objectstack/issues/8663), a write to an injected system column is no longer exempted on the strength of its NAME alone. The registry injects `owner_id` / `organization_id` / the audit family onto an ADR-0015 [`external` object](/docs/data-modeling/external-datasources) exactly as onto a local one, but the remote database owns that schema and no column exists behind them. Writing one raises `hook-body-write-unprovisioned-anchor` (or `action-body-write-unprovisioned-anchor` / `flow-node-write-unprovisioned-anchor` on the other two surfaces) — a **warning** on all three, including the flow-node rule that otherwise gates, because the claim is about a remote schema the build cannot see. A column you **declare** yourself is untouched: on a federated object a declared `owner_id` maps a remote column you vouch for. Why it matters more than an ordinary typo: an undeclared name is refused upstream by the engine's own write-path validator (`INVALID_FIELD`), whereas the injected anchor is in the registered schema and passes it — so it is the one payload key that reaches the remote database raw, where a SQL remote aborts the **whole statement** with an untyped `no such column` and takes the correctly named fields of the same payload with it.
222222
- **Checked — writes that reach nothing at all.** Since [#4345](https://github.com/objectstack-ai/objectstack/issues/4345), an action body assigning to `ctx.record` raises `action-record-write-discarded`, also a warning. This one is **not** a field-resolution question: an action's `ctx.record` is a snapshot the runtime never writes back, so the assignment is discarded whether or not the field is declared — see [Signature conventions](#signature-conventions) below.
223223

@@ -309,7 +309,7 @@ There is an asymmetry here that costs data if you learn it the hard way, so lear
309309

310310
The dropped case is the dangerous one: nothing fails, the step reports success, and the column is simply always null. Because both halves of that judgement are declared in your own stack, it is checked at author time and **gates the build**:
311311

312-
- `hook-api-update-readonly-field` — **error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` / `.insert()` writes a field the named object declares `readonly: true`. Since [#15394](https://github.com/objectstack-ai/objectstack/issues/15394) the `insert` row of the table above is reported at build time exactly like the `update` row — same id, same severity, a message naming the verb — unless the hook declares `runAs: 'system'`. Only the static shape is judged on an insert: a `readonlyWhen` field has no prior record to lock on and the engine runs no conditional strip on INSERT, so no warning is produced there.
312+
- `hook-api-update-readonly-field` — **error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` / `.insert()` writes a field the named object declares `readonly: true`, a field an `objectExtensions` entry of the same artifact adds to it included. Since [#15394](https://github.com/objectstack-ai/objectstack/issues/15394) the `insert` row of the table above is reported at build time exactly like the `update` row — same id, same severity, a message naming the verb — unless the hook declares `runAs: 'system'`. Only the static shape is judged on an insert: a `readonlyWhen` field has no prior record to lock on and the engine runs no conditional strip on INSERT, so no warning is produced there.
313313
- `hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: unlike the static strip, the conditional lock is **not** waived by a system context, so neither `runAs: 'system'` nor the `sudo()` a body cannot reach makes a caller-supplied value survive. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object.
314314

315315
Which hooks these rules can *see* depends on the command, because every rule in this family opens on `body.language === 'js'`. A hook authored as an inline `handler` function carries no `body`, so it is judged only where the command has first lowered the handler to a metadata body: `objectstack build` always has (it lowers before it parses — see [How the build lowers a handler](#build-pipeline)), and since [#16095](https://github.com/objectstack-ai/objectstack/issues/16095) `objectstack lint` judges that same lowered view, so an author who runs only the pre-flight is told the same thing the build would refuse. Since [#16544](https://github.com/objectstack-ai/objectstack/issues/16544) `objectstack validate` lowers before it parses as well, so all three commands judge the same view of a handler-authored hook — a stack `objectstack validate` passes is one `objectstack build` does not refuse on this family. A handler the build cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by the lowering rules instead, never guessed at here.

0 commit comments

Comments
 (0)