feat(lint): flow update_record 写不存在字段从盲区变为 gating error (#4271) - #4369
Merged
Conversation
…es the build (#4271) 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: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…4271) (#4374) #4369 shipped the flow write-set gate on `update_record` alone and parked `create_record` in FLOW_WRITE_NODE_TYPES_DEFERRED with its reason — a gating rule earning its severity one measured surface at a time, recorded as data rather than left as silence. This measures the other half and moves it across. The INSERT path fails the same way, one notch harder. Same literal `config.fields` map, same `objectName` binding, same journey to the driver — the engine hands an undeclared key to `driver.create` verbatim, alongside the audit stamps. On SQLite/knex it becomes `table deal has no column named stagee` and the statement is rejected whole, so the correctly named fields in the same payload never land either. The extra harm is what does NOT exist afterwards: the row is never created, so every later node reading `{<node>.id}` from that node's `outputVariable` is working from a record that was never written. An `update_record` failure at least leaves the record intact. The message names that consequence on `create_record` and only there, instead of one sentence blurred to fit both. Nothing else moves: same rule id, same `error` severity, the same silent bails, and `runAs` still not consulted. Each skip is pinned on the create surface as well as the update one, so the two node types cannot drift apart. FLOW_WRITE_NODE_TYPES_DEFERRED is now empty and deliberately kept: the partition test derives the full `fields`-write-map set behaviourally from the spec's executor-written config schemas, so a node type that grows one later belongs to neither list and fails that test until someone classifies it. Deleting the empty array would turn that forced decision back into a default. Two non-members are now excluded on the shape of their failure rather than by omission — `get_record.fields` is a projection (a READ, where an unknown entry narrows the selection instead of breaking the statement) and `screen.defaults` is forwarded into the ScreenSpec the client renders, so an unknown key is a prefill the renderer ignores. That inert case is what this rule's severity is defined against; a test pins that an out-of-ledger node type stays silent. Verified against the repo's own apps: app-crm, app-todo and app-showcase all still validate clean with create_record covered — including crm's convert-lead flow, which creates an account and an opportunity before updating the lead. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Jul 31, 2026
Closed
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…such field" (#4383) (#4385) `hook-body-write-unknown-field` and `action-body-write-unknown-field` reported EVERY field write to an object that declares no `fields` — an external object, or a datasource-introspected schema whose columns are resolved at runtime. `indexObjectFields` returns an empty Set for such an object rather than `undefined`, and both rules only asked "is this object in the stack?" (`targetSets.every((s) => s !== undefined)`, `if (!known) continue`). An empty Set is neither undefined nor falsy, so it became the answer to `has(field)` — and that answer is always false. The field map is not empty, it is UNKNOWN. Two other rules in the same family already drew that distinction with their reasons written down (validate-searchable-fields skip #2, and validate-flow-node-writes, which added the guard in #4369 because it gates). Two of four had it: the drift shape #3583 and #4330 exist to remove. Fixed once, not twice. The guard now lives in a shared `judgeableFieldsOf(index, objectName)` returning the declared names only when they are a sound basis for a "resolves to nothing" judgement, and `undefined` for both unjudgeable cases. All three write-set rules route through it, so a fourth cannot repeat the omission. Internal to the family — not re-exported from the package barrel, same as `indexObjectFields` and `IMPLICIT_FIELDS`. One semantic call worth naming: a multi-target hook where only SOME targets are judgeable is now skipped entirely. The `ctx.input` finding fires only when a field is missing from every target, and an unjudgeable target is one the field might well exist on — judging the remainder would assert "missing everywhere" on evidence that does not cover everywhere. Consistent with the rule's stated asymmetry: prefer a missed finding to a false one. No behaviour change for objects that declare fields; a test sits next to each new skip so the guard cannot swallow the real finding. 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
`validateReadonlyFlowWrites` 手工接进了 `os validate` 和 `os compile`,唯独
漏了 `os lint`。在 showcase 上植入一处违规(`runAs:'user'` 的 update_record
写静态 readonly 字段)实测:
os lint os validate
修复前 exit 0 放行 exit 1 拒绝
修复后 exit 1 拒绝 exit 1 拒绝
这条规则是 **gate** 级(静态 readonly + 字面字段是确定的 no-op:引擎从
UPDATE payload 里剥掉它,步骤照样报成功,#2948/#3425),所以漏接不是少一条
警告 —— 而是 `os lint` 放行了一个 `os validate` 会拦下的构建。
现接入 `REFERENCE_INTEGRITY_RULES`,两处手工接线随之删除,三个命令的答案
由构造保证一致,而不是靠三个人记得。这正是 suite 设立要终结的漂移
(#3583 §5 D5),而 suite 自己的头注释一直把这条规则当作该漂移的现行证据。
两件事让这个接线从"不整洁"变成"站不住":
- `validateFlowNodeWrites`(#4369)走的是**同一张** `config.fields` map,问的
是同一问题的另一半("字段存在吗" vs "字段可写吗"),而它已经在 suite 里。
一张 map、两个检查、两套命令集合。
- 两处手工接线连喂进去的输入都不一致:`validate` 喂 pre-parse 的
`normalized`,`compile` 喂 post-parse 的 `result.data`。合并到 suite 的
post-parse 输入前已实测两者对本规则等价,不丢 finding。
规则行为零变化:id、严重级、文案都不动。
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
#4305(hook body)和 #4344(action body)开出来的 write-set 规则家族有第三个面,而且正是文档最久推荐为"另外两个的安全替代"的那个。
flow
update_record节点的config.fields里写一个目标对象根本没声明的字段,没有任何东西会报:validate-readonly-flow-writes.ts走的就是这张 map,却显式跳过未知 key ——if (!meta) continue; // unknown field — a form/field-layout lint concern, not this rule's——转介给一条并不检查写侧的规则;
validate-flow-template-paths.ts检查的是插进节点 config 的{record.<path>}读 token,从来不看写侧的 key。#4355 刚刚把
hook-bodies.mdx改写为如实记录这个洞:本 PR 就是让这句话重新变成假的那一半。
新规则
flow-node-write-unknown-field接入
REFERENCE_INTEGRITY_RULES,所以os validate/os lint/os compile一次全覆盖 —— 比隔壁手工接线的 readonly 规则多一条路径。它 gate,两个兄弟规则只 advise —— 这是刻意的,不是不一致。
确定性对齐的是 readonly 规则,不是 body 规则。 hook/action 规则 advisory 是因为它们解析 JavaScript,findings 的质量取决于 extractor,一个误报就废掉一条 advisory lint。这里没有解析:
config.fields是字面 map,objectName是字面串——和隔壁同一个 config key、已经 gating 的flow-update-readonly-field是同一确定性。同一张 map 里,对"引擎会剥掉的写"报 error、对"根本不存在的列"只报 warning,不自洽。运行时后果不是"跳过未知名字、其余照常" —— 那是
page-field-unknown/form-field-unknown保持 advisory 的判据。两半都是实测,不是推断:driver.update(flow executor 直连 data engine;UPDATE 路径只剥 readonly/readonlyWhen;SQL driver 的formatInput/applyWriteColumnMap用m[k] ?? k直接放行);update "deal" set "name" = 'n2', "stagee" = 'won' … → no such column: stagee。语句整条被拒:同一 payload 里拼对的name也没落库(实测该行仍是旧值),报错点离作者的笔误很远;这正是
validate-searchable-fields对 stale entry、validate-flow-template-paths对 filter 位置 token 的判据:miss 会打断或污染操作就 gate,只是让输出变窄就 advise。三个面共用一套定义
indexObjectFields和IMPLICIT_FIELDS从 hook 规则 import 而非复制,所以三条规则对"什么字段不写在fields里也可写"给出同一个答案 —— 避免 #4330 在隔壁包收拾掉的那种漂移。静默 bail 的边界
每一条都是为了让 gate 只在确定的事情上开火:模板化
objectName、非字面fields、跨包对象、声明零字段的对象(external / 数据源自省 schema,与validate-searchable-fields同一跳过)、点号 key。runAs刻意不看 —— 与跳过runAs:'system'的 readonly 规则相反:提权能绕过 readonly strip,但没有任何运行身份能变出一列。覆盖范围写成数据
FLOW_WRITE_NODE_TYPES(当前仅update_record)+FLOW_WRITE_NODE_TYPES_DEFERRED(create_record,带理由),对着 spec 里 executor 推导出的 config schema 行为式做 partition 测试 —— 将来哪个节点类型长出fields写 map,会直接测试失败逼人分类,而不是静默留在没覆盖的一半。验证
@objectstack/lint42 文件 / 654 测试全绿;@objectstack/cli64 文件 / 628 测试全绿;pnpm --filter @objectstack/lint typecheck、check:type-check-coverage、speccheck:api-surface、check:docs、check:doc-authoring全部干净。priority→priorityy,os validateexit code 0 → 1,报出 did-you-mean、rule id 和flows[1].nodes[3].config.fields.priorityy;还原后回到 0。文档
hook-bodies.mdx与hooks.mdx里 #4355 刚写下的那两句"这一轴上 hook body 反而是更好检查的面",改成轴已经翻回来 —— 并说明 flow 侧比 body 侧高一级而不只是持平。@objectstack/spec:ScriptBodySchema的 "prefer a flowupdate_recordnode, whose structuralfieldsconfig is error-checked" 注释现在点名了让这句成立的规则。纯注释,无 schema / 生成物变更(check:api-surface已确认)。🤖 Generated with Claude Code