Skip to content

feat(lint): flow update_record 写不存在字段从盲区变为 gating error (#4271) - #4369

Merged
os-zhuang merged 1 commit into
mainfrom
claude/trusting-raman-2e562d
Jul 31, 2026
Merged

feat(lint): flow update_record 写不存在字段从盲区变为 gating error (#4271)#4369
os-zhuang merged 1 commit into
mainfrom
claude/trusting-raman-2e562d

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

背景

#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 改写为如实记录这个洞:

Prefer a flow update_record node when the write set is fixed — but not for this check. … writing a field the object never declares is currently reported by nothing at all. On that one axis an L2 body is now the better-checked surface.

本 PR 就是让这句话重新变成假的那一半。

新规则 flow-node-write-unknown-field

接入 REFERENCE_INTEGRITY_RULES,所以 os validate / os lint / os compile 一次全覆盖 —— 比隔壁手工接线的 readonly 规则多一条路径。

它 gate,两个兄弟规则只 advise —— 这是刻意的,不是不一致。

  1. 确定性对齐的是 readonly 规则,不是 body 规则。 hook/action 规则 advisory 是因为它们解析 JavaScript,findings 的质量取决于 extractor,一个误报就废掉一条 advisory lint。这里没有解析:config.fields 是字面 map,objectName 是字面串——和隔壁同一个 config key、已经 gating 的 flow-update-readonly-field 是同一确定性。同一张 map 里,对"引擎会剥掉的写"报 error、对"根本不存在的列"只报 warning,不自洽。

  2. 运行时后果不是"跳过未知名字、其余照常" —— 那是 page-field-unknown / form-field-unknown 保持 advisory 的判据。两半都是实测,不是推断:

    • 过引擎时未声明的 key 原样到达 driver.update(flow executor 直连 data engine;UPDATE 路径只剥 readonly/readonlyWhen;SQL driver 的 formatInput / applyWriteColumnMapm[k] ?? k 直接放行);
    • SQLite/knex 上变成 update "deal" set "name" = 'n2', "stagee" = 'won' … → no such column: stagee。语句整条被拒:同一 payload 里拼对的 name 也没落库(实测该行仍是旧值),报错点离作者的笔误很远;
    • schemaless 数据源上没人拒,于是脏 key 落进一个对象从未声明的列,任何 schema 驱动的读面都不会返回它。

    这正是 validate-searchable-fields 对 stale entry、validate-flow-template-paths 对 filter 位置 token 的判据:miss 会打断或污染操作就 gate,只是让输出变窄就 advise

三个面共用一套定义

indexObjectFieldsIMPLICIT_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/lint 42 文件 / 654 测试全绿;@objectstack/cli 64 文件 / 628 测试全绿;pnpm --filter @objectstack/lint typecheckcheck:type-check-coverage、spec check:api-surfacecheck:docscheck:doc-authoring 全部干净。
  • 三个 example 全部通过新 gating:app-crm、app-todo、app-showcase。
  • 端到端证明规则真的接上了:往 app-todo 植入 prioritypriorityy,os validate exit code 0 → 1,报出 did-you-mean、rule id 和 flows[1].nodes[3].config.fields.priorityy;还原后回到 0。

文档

hook-bodies.mdxhooks.mdx#4355 刚写下的那两句"这一轴上 hook body 反而是更好检查的面",改成轴已经翻回来 —— 并说明 flow 侧比 body 侧高一级而不只是持平。

@objectstack/spec:ScriptBodySchema 的 "prefer a flow update_record node, whose structural fields config is error-checked" 注释现在点名了让这句成立的规则。纯注释,无 schema / 生成物变更(check:api-surface 已确认)。

🤖 Generated with Claude Code

…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>
@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 9:17am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Jul 31, 2026
@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.

@os-zhuang
os-zhuang merged commit cc60165 into main Jul 31, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/trusting-raman-2e562d branch July 31, 2026 09:30
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant