Skip to content

feat(lint): action body 里落不了地的 ctx.record 写在作者时告警 (#4345) - #4362

Merged
os-zhuang merged 3 commits into
mainfrom
claude/action-record-write-lint-4345
Jul 31, 2026
Merged

feat(lint): action body 里落不了地的 ctx.record 写在作者时告警 (#4345)#4362
os-zhuang merged 3 commits into
mainfrom
claude/action-record-write-lint-4345

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4345.

背景

#4344 刻意没碰 ctx.record,并写明了理由:action 的 ctx.record 是一份普通快照(unwrapProxyToPlain(actionCtx?.record)),boundActionHandler 从不写回 —— hook 路径的 applyMutationsToInput 在 action 侧没有对应物 —— 所以 ctx.record.x = …已声明和未声明字段一样被丢弃。

把它塞进 unknown-field 规则会是错的:只报未声明的那一半,等于暗示已声明的那一半会落库,正是这一族规则要消灭的假完成。它需要自己的 finding,现在有了。

新规则 action-record-write-discarded(advisory)

它不是"看见 ctx.record.<field> = … 就报" —— 把快照当 payload 攒起来是合法写法:

ctx.record.stage = 'won';
await ctx.api.object('crm_deal').update(ctx.record);   // 这个写是活的

所以 finding 要求这个写可证死:ctx.record 在整段 body 里从未作为流出去。属性读(ctx.record.id)不赦免那些写;把对象本身交出去 —— 当参数、当赋值右值、展开、return —— 才会。别名 const r = ctx.record 也算流出,方向是安全的:代价是漏报,不是误报。

真值判断不算流出 —— 这条是拿 showcase 跑出来的

mark_done 开头就是

var id = ctx.recordId || (ctx.record && ctx.record.id) || input.recordId;

这是 action body 真实的写法。第一版把 ctx.record && 这个守卫当成流出,于是仓库里唯一一段带 record 写的 body 被自己噤声了。真值/类型判断读一下引用、产出一个布尔 —— 或者对 &&/||/?? 而言,只在左操作数为假时产出它,而假值是 null/undefined,什么都持久化不了。只有操作数是判断:x || ctx.record 确实会求值成那个对象,仍然算流出。

一个 suite 成员,两个 rule id

两个 finding 出自同一次 parse、同一段源码、同一个面。再加一个 REFERENCE_INTEGRITY_RULES 成员,就要把每个 action body 解析两遍去说同一次遍历的两件事;而手工挂进三个 CLI 命令,正是这个 suite 要消灭的 drift —— validateReadonlyFlowWrites 就是活证据:挂了 validatecompile,唯独漏了 lint。取舍在 suite 注释和模块头两处都写下来了,不留给下一个人重新发现。

台账棘轮第一次真的救了场

record-property-assign 进入共享的 HOOK_BODY_WRITE_PATTERNS —— 那是抽取器的形状清单,不属于任何一条规则 —— 两个既有消费者必须先分类,它才能落地。

这在 hook 侧不是走过场:record 写没有 object,而 validateHookBodyWrites 正是靠"没有 object"判定"这是 ctx.input 写"的,新形状会被报成 "the hook writes 'stage' to its input"。现在 hook 规则也声明了自己消费的子集(HOOK_BODY_WRITE_PATTERN_IDS)和带理由的排除项 —— hook 沙箱上下文压根没有 ctx.record(buildSandboxContext 从不设置),写它是运行时抛错而不是静默失效;响的失败不归 advisory 规则管。

extractHookBodyWriteSet 是新的单次解析入口(writes + ctxRecordEscapes),extractHookBodyWrites 退化成它的薄投影。

boot path

action 侧的预过滤从 api 放宽到 apirecord,两者都不沾的 body 依然连 parse 都不做,更不会加载 ~9 MB 的 TypeScript。lazy-deps.test.ts 钉住这条。

顺带修掉那份文件里已经过期的表述:头注释和两个 dist 探针的用例名还在声称每个惰性依赖都只等"一个 react page",而 typescript 从 #4271 起就也由 hook body gate 加载了。现在每个用例说清自己钉的是哪个触发点。

文档

content/docs/automation/hook-bodies.mdx 两个 commit 前刚被 #4351/#4355 重写过(为了不再声称写面没检查),而那一节正落在本 PR 的题目上,所以在同一个 PR 里更新,而不是让它第二次变成假的:写法表加了 ctx.record 行(也不再是"三种形状"),静默 bail 清单加了流出规则,Signature conventions 直接讲明 action 的 ctx.record 实质只读、ctx.api.object(...) 是 body 持久化的唯一路径。

验证

结果
@objectstack/lint test 634 passed
@objectstack/cli test 628 passed
@objectstack/lint typecheck clean
@objectstack/spec check:generated 8/8 up to date(只动 JSDoc/文档)
eslint(改动文件) 0 errors
showcase os validate 基线 0 条;注入一处死写后恰好 1 条,带正确 hint;还原后回到 0

ScriptContext.recordScriptBodySchemaActionSchema.body 都写明了实质只读。运行时到底该拒绝还是该兑现这个写,仍然开在 #4345 上 —— 本 PR 只让它在作者时可见,不替那个决定下结论。

🤖 Generated with Claude Code

os-zhuang and others added 3 commits July 31, 2026 16:48
#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>
…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.
@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 8:54am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

114 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 packages/runtime, @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/runtime, @objectstack/spec)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • 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, @objectstack/runtime, 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 @objectstack/runtime, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/runtime, packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime, @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/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • 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/runtime, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime, @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/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint, packages/runtime, @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/runtime, @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/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime, @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/runtime, @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.

@os-zhuang
os-zhuang merged commit 0ecc656 into main Jul 31, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/action-record-write-lint-4345 branch July 31, 2026 09:07
xuyushun441-sys pushed a commit that referenced this pull request Jul 31, 2026
#4362(已合并)为 #4345 落了 lint 规则 `action-record-write-discarded`,而且
在一处比本分支原来的规则正确:它要求写是**可证明的死写**(`ctx.record` 不逃逸)。
因为这是活的、确实落库的合法写法:

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

本分支原来的 lint 规则会把它误报成"被丢弃",而且断言"stored record is
unchanged" —— 那是一句错话,不只是噪音。实测确认后:

- 删掉本分支的 `validate-action-record-writes.{ts,test.ts}` 及其全部接线,
  lint 包与 hook-bodies.mdx 整体采用 main 的版本;
- **同一个缺陷也在本分支的运行时层**:它对上面那段一样报 dropped。补了逃逸
  检测(写之后出现 `ownKeys` 即视为逃逸,proxy 上观察),语义与 #4362 对齐:
  整体消费(交给 ctx.api、展开、返回、JSON.stringify)噤声,属性读不算解救,
  所以 showcase 的 `ctx.recordId || (ctx.record && ctx.record.id)` 惯用法
  照报。

保留的只剩 #4362 明确留作 open question、且 lint 够不着的那一层:运行时在
调用时报告被丢弃的写 —— 覆盖 computed key、别名、整体替换,以及 Studio / API
写入的 metadata(lint 永远看不到)。它只报告,不 refuse 也不 honour,不预设
那个 open question 的答案。

changeset 相应收窄为 @objectstack/runtime 单包。

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.

action body 里写 ctx.record 被静默丢弃 —— 已声明字段也一样(#4271 家族)

1 participant