Skip to content

feat(lint,spec): L2 action body 写不存在字段从盲区变为作者时 lint 告警 (#4271) - #4344

Merged
os-zhuang merged 2 commits into
mainfrom
claude/action-body-write-lint-4271
Jul 31, 2026
Merged

feat(lint,spec): L2 action body 写不存在字段从盲区变为作者时 lint 告警 (#4271)#4344
os-zhuang merged 2 commits into
mainfrom
claude/action-body-write-lint-4271

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

背景

#4305(commit c1d44f7)让 L2 hook body 的写集在作者时可查。但 action body 是同一个artefact:同一个 HookBodySchemaactionBodyRunnerFactory 里同一个 HookBodySchema.safeParse、同一个 QuickJS 沙箱。所以它以同样的方式失败 —— action 里写

await ctx.api.object('crm_deal').update({ stag: 'won' });

跑通、返回成功给调用方,未知列静默不落库。写面还有一半是盲的。

这个 PR

新规则 action-body-write-unknown-field(advisory,永不 gate),挂进 REFERENCE_INTEGRITY_RULES,所以 os validate / os lint / os compile 一起报。

runtime 从两处读 action(collectBundleActions:顶层 actionsobjects[].actions),两处都走;defineStack 合并过的 action 同时存在于两处,只报一次,报在作者写的位置。

只移植 ctx.api 一族 —— 这是重点,不是省事

ctx.input 不适用。 action 的 ctx.input 是它的 params 包(input: unwrapProxyToPlain(actionCtx?.params)),不是记录。拿这些名字去对象字段里找,会把每一个拼写正确的参数都告警一遍 —— 纯误报机器,而一条误报就足以让一个 advisory lint 被无视。

ctx.record 也不是写面。 runner 交给 body 的是一份普通快照(record: unwrapProxyToPlain(actionCtx?.record)),boundActionHandlerreturn result.value,没有 applyMutationsToInput(那是 hook 路径独有的)。所以 ctx.record.x = …已声明字段和未声明字段一样被丢弃。这跟"未知列静默消失"是两种缺陷;只报未声明的那一半,等于暗示已声明的那一半会落库 —— 恰好是这个规则家族要消灭的假完成。单独开了 issue 跟踪,不在本 PR 混做。

台账做成 partition,不是第二份清单

  • ACTION_BODY_WRITE_PATTERN_IDS(今天只有 api-crud-literal)
  • ACTION_BODY_WRITE_EXCLUSIONS(两条 input 族,各带 reason)

测试断言两半恰好覆盖 HOOK_BODY_WRITE_PATTERNS。以后 hook 侧加第四条模式,这个规则的测试直接红,逼人做分类决定 —— 沉默不算决定。

用 include-list 而非 exclude-list 是有意的:未分类的新模式在这里是哑的(漏报),不会带着没推敲过的语境上线(误报)。跟 extractor 的静默 bail 是同一个不对称。

延续 #4305 的 reconciliation 姿态,并且更严一档:

  • 每条 applicable 模式端到端过完整 validator 验一遍 —— prefilter、模式过滤、字段判定都在例子和 finding 之间,任一环节静默吞掉都会被抓;
  • 每条 exclusion 验的是"共享 extractor 仍然抽得到,但本规则不报" —— 证明排除的是适用性,不是抽不出来。

extractor、字段索引、系统列集合都从 hook 模块复用(SYSTEM_FIELDS 提升为 BODY_WRITE_SYSTEM_FIELDS 并导出,不进 barrel)。两份 system 列清单迟早会给出两个不同答案。

一个跑出来的真 bug

拿 showcase 打反例(progressprogres)跑 pnpm validate,同一条告警报了两次

defineStackmergeObjectActions 把带 objectName 的 action 追加进对象、同时保留顶层条目;而 suite 跑在 Zod parse 之后的 stack 上,parse 会重建每个节点 —— 两份副本"结构相等、引用不同",身份去重(runtime 用的那种)在这里等于没去重。单测用共享引用的 fixture 是过得去的假测试

改为按值去重(绑定对象 + name + body source),单测改成用两个克隆对象。复跑:恰好 1 条,带 Did you mean 'progress'?。还原后 showcase 本身零告警(真实 app 上无误报)。

boot path

比 hook 侧紧一档:唯一适用的模式根在 ctx.api,所以 body 不含 api 标识符就连 parse 都不做,更别说加载 ~9 MB 的 TypeScript。lazy-deps.test.ts 的 in-process 与两个 dist 探针都加了这条契约。

spec

ScriptBodySchemaActionSchema.body 指向 action 侧规则,并写明 ctx.input(params)与 ctx.record(会被丢弃的快照)都不是记录写面。只动 JSDoc,没碰 .describe() —— check:generated 8/8 全部 up to date。

验证

结果
@objectstack/lint test 607 passed
@objectstack/spec test 7153 passed
@objectstack/cli test 628 passed
lint / spec typecheck clean
@objectstack/spec check:generated 8/8 up to date
eslint(改动文件) 0 errors
showcase os validate 反例恰好 1 条(带 did-you-mean);还原后 0 条

🤖 Generated with Claude Code

The write-set lint #4305 gave L2 hook bodies now covers the other surface that
carries one. An action body is the same artefact — same `HookBodySchema`, same
`HookBodySchema.safeParse` in `actionBodyRunnerFactory`, same QuickJS sandbox —
so it fails the same way: `ctx.api.object('crm_deal').update({ stag: 'won' })`
succeeds, returns success to the caller, and the unknown column never lands.

New rule `action-body-write-unknown-field`, advisory, wired into
REFERENCE_INTEGRITY_RULES so `os validate` / `os lint` / `os compile` all report
it. Both places the runtime reads actions from are walked (top-level `actions`
and `objects[].actions`); a defineStack-merged action, which lives in both, is
reported once at its authored path.

Only the `ctx.api` write family carries over. An action's `ctx.input` is its
PARAMS bag, not a record, so resolving those names against object fields would
flag every correctly-named parameter. `ctx.record` is not a write surface
either: the runner hands the body a plain snapshot and never writes it back, so
`ctx.record.x = …` is discarded for declared and undeclared fields alike — a
different defect, and flagging only its undeclared half would imply the
declared half persists.

So the rule ships a declared PARTITION of the shared HOOK_BODY_WRITE_PATTERNS
— ACTION_BODY_WRITE_PATTERN_IDS plus ACTION_BODY_WRITE_EXCLUSIONS, each
exclusion carrying its reason — tested to cover the shared ledger exactly, so a
fourth pattern landing on the hook side fails this rule's test until someone
classifies it. Every applicable pattern is proved end-to-end through the full
validator; every exclusion is proved extractable-but-inapplicable. One
extractor, one field index, one system-column set, shared rather than copied.

The dedupe is by value (bound object + name + body source), not by object
identity the way collectBundleActions can afford: the suite runs on the parsed
stack and parsing rebuilds every node, so a merged action's two copies arrive
distinct-but-equal. An identity check passed the unit fixture and reported the
showcase app's one warning twice — caught by running `os validate` on it.

Lands one notch tighter than the hook side on the boot path: the only
applicable pattern is rooted at `ctx.api`, so an action body that never
mentions it does not parse at all, let alone load the ~9 MB TypeScript
compiler. Guarded by lazy-deps.test.ts.

spec: ScriptBodySchema and ActionSchema.body point at the action-side rule and
spell out that ctx.input (params) and ctx.record (a discarded snapshot) are not
record-write surfaces. Doc comments only — all 8 generated artifacts verified
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 31, 2026 7:14am

Request Review

Conflict in validate-hook-body-writes.ts, resolved in main's favour and then
some. #4339 replaced this rule's hand-copied `SYSTEM_FIELDS` list with
`IMPLICIT_FIELDS` — derived from the spec via the package-shared
`system-fields.ts` (#4330) — which supersedes the `BODY_WRITE_SYSTEM_FIELDS`
rename this branch had introduced for the same reason. So the branch drops its
rename and exports `IMPLICIT_FIELDS` instead: the action rule shares the
derived set rather than the hand-copied one, which is strictly the outcome both
changes were after.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec.

107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests protocol:ui tooling labels Jul 31, 2026
@os-zhuang
os-zhuang merged commit 6a67d7a into main Jul 31, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/action-body-write-lint-4271 branch July 31, 2026 07:27
os-zhuang added a commit that referenced this pull request Jul 31, 2026
…the section #4305/#4344 made false (#4351) (#4355)

`content/docs/automation/hook-bodies.mdx` claimed the write side had **no**
static checking at all, and called it an "accepted gap with no planned
closure". Since #4305 (`validateHookBodyWrites`) and #4344 (the action-side
sibling) that is false: `hook-body-write-unknown-field` and
`action-body-write-unknown-field` parse an L2 body, resolve its literal writes
against the target object's declared fields, and warn with a did-you-mean.

The mirror image of "declared ≠ enforced": here the capability shipped and the
docs still said it had not. An author who drops a real guardrail because the
docs said it does not exist pays the same price as one who trusts a guardrail
that does not.

- "Not statically checked: the write set" → "Write-set checking". Coverage
  described accurately: the three literal patterns (tabulated per surface,
  since an action's `ctx.input` is its params bag and only `api-crud-literal`
  carries over), advisory `warning` that never gates, and the shapes the parser
  must skip — computed keys, spreads, dynamic object names, wildcard
  `ctx.input`, partial multi-target hits, cross-package targets, aliased input
  — so the ABSENCE of a warning is not read as proof of correctness.
- #3700 (structured `writes`, closed as not planned) stays as accurate history,
  but the "accepted gap" conclusion is retracted: the gap was closed from the
  other end, by parsing the write set out of the source.
- Three now-stale cross-links in `hooks.mdx` follow the renamed anchor. The
  wildcard bullet gets sharper rather than deleted — a wildcard hook's
  `ctx.input` writes are exactly the shape the lint must skip.

Two claims were corrected against measured behaviour rather than restated:

- **Flow `update_record` has no unknown-field check.** The old advice
  ("prefer a flow node, they get the static checking hook bodies can't") is
  now backwards on this axis. Verified on main: for the same undeclared field,
  the hook body and action body each warn and the `update_record` node reports
  nothing — `validateReadonlyFlowWrites` walks its `fields` but skips unknown
  ones by design (`if (!meta) continue`). The recommendation is kept for what
  is still true (structured diffing, the gating `flow-update-readonly-field`)
  with the gap named.
- **"Silently never lands" is wrong in both directions.** The lint's own
  message says the unknown column quietly vanishes. Measured: nothing strips
  it — `applyMutationsToInput` is a plain `Object.assign`, `validateRecord`
  walks declared fields only — so it reaches the driver. On SQL the whole write
  FAILS (`table deal has no column named stagee`, zero rows stored); on memory/
  MongoDB the stray key IS persisted. The doc's runtime paragraph was right and
  is kept, now split by driver family. The lint's wording is tracked separately.

Docs only — no behaviour change, empty changeset.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
xuyushun441-sys pushed a commit that referenced this pull request Jul 31, 2026
Both landed on the same surface while this branch was open, so the merge is a
reconciliation rather than a textual resolution:

- #4344 built the same `actions` + `objects[].actions` walk, with the same
  value-dedupe of defineStack's merged copies (it found the double-report the
  same way). Two walks over one surface would drift silently, so its
  `collectActionBodies` is now exported (module-level, not via the barrel —
  same posture as BODY_WRITE_SYSTEM_FIELDS), carries the bound object, and this
  rule consumes it. The duplicate walk and its helpers are deleted.

- The two rules split one write surface and answer different questions, so the
  suite, the barrel and the spec JSDoc now say which is which: `ctx.api` writes
  → does this field exist (did-you-mean); `ctx.record` writes → none of them
  land, declared or not (no did-you-mean, because the name is not the bug).

- #4351/#4355 rewrote the hook-bodies write-set section this branch also
  edited. Its write-shape table listed three shapes and said nothing about
  `ctx.record`, which this branch now checks — added the row plus the note that
  it is a different defect, and that the record rule's statically-opaque cases
  are covered by the runtime recorder instead.

Verified after the merge: lint 642, spec 7160, runtime 989, cli 628, spec
check:generated 8/8, typecheck + eslint clean, showcase 0 findings clean and
exactly 1 with the repro planted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 31, 2026
* feat(lint): action body 里落不了地的 ctx.record 写在作者时告警 (#4345)

#4344 deliberately left `ctx.record` alone and said why: an action's
`ctx.record` is a plain snapshot (`unwrapProxyToPlain(actionCtx?.record)`) that
`boundActionHandler` never writes back — the hook path's
`applyMutationsToInput` has no action-side counterpart — so `ctx.record.x = …`
is discarded for DECLARED and undeclared fields alike. Reporting that through
the unknown-field rule would have been actively wrong: flagging only the
undeclared half implies the declared half persists. It needed its own finding.

New rule `action-record-write-discarded`, advisory.

It is NOT "flag every ctx.record assignment" — mutating the snapshot to build a
payload is legitimate:

    ctx.record.stage = 'won';
    await ctx.api.object('crm_deal').update(ctx.record);   // LIVE

So the finding requires the write to be provably dead: `ctx.record` never
escapes the body as a value. Property reads do not rescue a write; handing the
object to anything — argument, assignment RHS, spread, return — does. Aliasing
reads as an escape, the safe direction.

Truthiness and type tests are NOT escapes, and that is what makes the rule fire
on real code. Running it against the showcase surfaced it: `mark_done` opens
with `ctx.recordId || (ctx.record && ctx.record.id)` — the idiom action bodies
are actually written with — and counting that guard as an escape silenced the
finding on the one body in the repo that had a record write. Only the LEFT
operand of &&/||/?? is a test; `x || ctx.record` still escapes.

One suite member, two rule ids: both findings fall out of one parse of one
source on one surface. A second REFERENCE_INTEGRITY_RULES member would parse
every action body twice to say two things about one walk, and hand-wiring it
into the three CLI commands is the drift that suite exists to end —
validateReadonlyFlowWrites is the standing proof, wired into validate and
compile but never into lint. Trade-off written down at both ends.

The ledger ratchet fired as designed. `record-property-assign` joins the shared
HOOK_BODY_WRITE_PATTERNS — the extractor's shape inventory, not any one rule's
— and both consumers had to classify it first. Not cosmetic on the hook side: a
record write carries no `object`, and validateHookBodyWrites branched on
exactly that to mean "a ctx.input write", so the new shape would have been
reported as "the hook writes 'stage' to its input". The hook rule now declares
its own consumed subset and its exclusion with a reason — a hook sandbox
context has no ctx.record at all, so the expression throws at run time rather
than silently no-op'ing, and a loud failure is not an advisory rule's business.

extractHookBodyWriteSet is the new one-parse entry point (writes +
ctxRecordEscapes); extractHookBodyWrites stays a thin projection.

Boot path: the action prefilter widens from `api` to `api`-or-`record`, so a
body reaching neither still never loads the ~9 MB TypeScript compiler.
lazy-deps.test.ts pins it — and its header plus two case names, which still
claimed every lazy dep waited on "a react page", now name the trigger each one
pins (typescript has also been loaded by the hook-body gate since #4271).

spec/runtime: ScriptBodySchema, ActionSchema.body and ScriptContext.record now
state that ctx.api.object(...) is the only path that persists anything and that
ctx.record is read-only in effect. Doc comments only — all 8 generated
artifacts verified unchanged. Whether the runtime should instead refuse or
honour a record write stays open on #4345.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Merge origin/main; document the ctx.record write in the section #4351 just rewrote

#4351/#4355 rewrote content/docs/automation/hook-bodies.mdx to stop claiming the
write side was unchecked. That section lands squarely on this branch's subject,
so it is updated here rather than left false for a second time: the write-shape
table gains the ctx.record row (and is no longer 'three shapes'), the silent-bail
list gains the escape rule, and Signature conventions now says outright that an
action's ctx.record is read-only in effect and ctx.api.object(...) is the one way
a body persists anything.

---------

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 31, 2026
…es the build (#4271) (#4369)

The write-set family #4305 (hooks) and #4344 (actions) opened had a third
surface, and it was the one the docs had spent the longest recommending as the
safe alternative to the other two. A flow `update_record` node whose
`config.fields` names a field the target object never declares was caught by
NOTHING: `validate-readonly-flow-writes.ts` walks that exact map and explicitly
stepped over the unknown key (`if (!meta) continue; // a form/field-layout lint
concern` — a referral to a rule that does not check writes), and
`validate-flow-template-paths.ts` checks the `{record.<path>}` READ tokens
interpolated into node config, never the write-side key.

New rule `flow-node-write-unknown-field`, wired into REFERENCE_INTEGRITY_RULES
so `os validate`, `os lint` and `os compile` report it at once — one more path
than the hand-wired readonly rule next door reaches.

It GATES where its two siblings advise. The hook and action rules are advisory
because they PARSE JavaScript: the finding is only as good as the extractor.
Nothing here is parsed — `config.fields` is a literal map next to a literal
`objectName`, the same certainty `flow-update-readonly-field` already gates on
one config key over. A rule that errors on a write the engine strips while only
warning on a write that names no column at all would be incoherent in the same
map.

And the runtime consequence is not the benign "consumer skips the unknown name
and renders the rest" that keeps page-field-unknown / form-field-unknown
advisory. Both halves were measured: through the engine an undeclared key
reaches `driver.update` verbatim, and on SQLite/knex it becomes `no such
column: stagee` — the statement is rejected WHOLE, so the correctly named
fields in the same payload never land either. On a schemaless datasource the
stray key is persisted into a column no schema-driven read returns.

One field index and one implicit-field set across all three surfaces:
`indexObjectFields` and `IMPLICIT_FIELDS` are imported from the hook rule
rather than copied. Every skip is silent and exists so the gate only fires on a
certainty — templated `objectName`, non-literal `fields`, cross-package
objects, objects declaring no fields at all (external / introspected schemas),
dotted keys. `runAs` is deliberately not consulted, unlike the readonly rule:
an elevated identity bypasses the readonly strip, but no run identity conjures
a column.

Scope is declared as data: FLOW_WRITE_NODE_TYPES (today `update_record`) and
FLOW_WRITE_NODE_TYPES_DEFERRED (`create_record`, with its reason) are
partition-tested against the CRUD node types that carry a `fields` write map,
derived behaviourally from the spec's executor-written config schemas.

Docs: automation/hook-bodies.mdx and automation/hooks.mdx carried the pre-#4271
framing (write side "not checked", an "accepted gap with no planned closure")
long after both body rules landed. Both now state the real asymmetry, with an
explicit callout that a clean run is not proof.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant