Skip to content

ActionParam.validation / ActionParamDef.validation is declared, never read, and rejected by the spec parser #3201

Description

@os-zhuang

Found while fixing objectui#3174 (the referenceTo vs reference spelling split). Recording only — out of scope there, because retiring it is its own decision with its own blast radius.

The gap

validation?: string is declared on both halves of the action-param contract:

  • authoring — @object-ui/types' ActionParam (packages/types/src/ui-action.ts)
  • resolved — @object-ui/core's ActionParamDef (packages/core/src/actions/ActionRunner.ts)

Nothing reads it, on either side:

  • resolveActionParams() (packages/app-shell/src/utils/resolveActionParams.ts) never copies it onto the resolved param — it is not in RawActionParam at all.
  • paramToField() (packages/app-shell/src/utils/paramToField.ts) never maps it into the field object the widgets consume.
  • buildValidationRules() in @object-ui/fields builds rules from required / minLength / maxLength / pattern / type field metadata. There is no validation branch, so even if it reached the field it would be inert.

And it is not authorable either: ActionParamSchema in @objectstack/spec/ui is .strict() with a custom unknown-key error, and validation is not in its ACTION_PARAM_KEYS. So an authored validation: '...' is a hard parse rejection on the server, while tsc against the public type accepts it.

That is exactly the objectui#3174 shape — declared, inert, and blessed by a type the platform's own parser refuses — minus the second spelling that made #3174 worse.

Why it was left standing in #3174

#3174's fix removed the nine resolved-side picker keys (referenceTo, displayField, idField, descriptionField, titleFormat, lookupColumns, lookupFilters, lookupPageSize, dependsOn) from the authoring ActionParam, and installed a drift guard pinning the rule "the authoring type declares exactly the spec's authorable keys". validation is carried as the one named exception in that guard, so a second undeclared local key cannot appear without a decision — but the exception itself is debt, and this issue is the ledger entry for it.

The decision this needs

  1. Remove it from ActionParam (and probably ActionParamDef too), on the enforce-or-remove reading: it can never work as authored, so declaring it can only mislead. Breaking change to two published types; needs a changeset.
  2. Implement it — decide what an "expression" means here (CEL? a formula? a regex?), add it to ActionParamSchema in @objectstack/spec (cross-repo contract change), have the resolver carry it and paramToField lower it into a real validation rule.

Option 1 is the ADR-0049 default and the cheaper honest answer; option 2 is only worth it if per-param validation is a capability we actually want, in which case the spec is where it starts. Either way the gate is a test asserting that whatever the authoring type declares, the param dialog enforces.

Acceptance

Activity

  1. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Contributor

    认领:PM 循环第 6 轮 · v17 发布窗口:协议类优先
    会话:session_01NVPjPzmmAJ2Ngtvgg5MSRa(objectui 分片 PM,账号 xuyushun441-sys)
    分支:claude/issue-3201-retire-actionparam-validation
    Worktree:objectui-issue-3201

    裁定:选项 1(移除),撤下 needs-user-decision

    本单给了两条路,并且自己就说清了默认值:「选项 1 是 ADR-0049 的默认,也是更便宜、更诚实的答案;选项 2 只有在我们确实想要逐参数校验这个能力时才值得」。

    在 v17 发布窗口里这个判断更硬,不是更软:

    • 选项 2 是跨仓新增能力 —— 要先定义"expression"是什么(CEL?公式?正则?),再改 @objectstack/spec 的 ActionParamSchema,再让 resolver 与 paramToField 一路下沉。发布窗口不是开新契约的时候。
    • 现状是 declared ≠ enforced 的教科书形态:validation 在两个已发布类型上被声明,tsc 接受它,而服务端 ActionParamSchema 是 .strict() 且 ACTION_PARAM_KEYS 里没有它 —— 作者写了它就是一次硬解析拒绝。一个"类型说合法、运行时当场拒收"的键,正是 ADR-0049 enforce-or-remove 要清的东西,也正是 v17 该清干净的东西。

    按两条轴:长远合理性 —— 留着它等于让公共类型继续为一个平台自己拒收的键背书;防 AI 写错 —— AI 作者读的就是这个类型,它今天在主动教人写一个必被拒的键,而 tsc 不会拦。

    派发范围

    1. 从 @object-ui/types 的 ActionParam(packages/types/src/ui-action.ts)移除 validation;
    2. @object-ui/core 的 ActionParamDef(packages/core/src/actions/ActionRunner.ts)同样处理 —— 先确认 resolved 侧真的没有消费者(单子说 resolveActionParams() 压根没把它放进 RawActionParam),核实结论正反都写进报告;
    3. 清掉 An authored ActionParam.referenceTo is never read — the dev warning tells authors to use reference, which the public type didn't declare #3174 漂移守卫里的具名例外 —— packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts 目前把 validation 作为唯一具名例外携带着。本单的验收标准就是这条例外不再需要(或换成一个有正当理由的新例外)。这是本单真正的终点:守卫回到"作者类型精确等于 spec 的可作者化键",不带例外。

    边界与注意

    ⛔ 不要在 spec 里新增 validation —— 那是选项 2,已否决。⛔ 不碰 packages/spec(跨仓,归主 backlog PM)。

    ⚠️ 这是两个已发布类型的破坏性移除,需要 changeset。但注意本仓 changeset-guard(检查名 Changeset Bump Policy)禁止 major;按仓库版本策略走 patch/minor 并在 changeset 正文里写清 FROM → TO 与迁移指引(「若你声明了 validation,它从未生效且会被服务端拒收,删掉即可」)。若门禁与你的判断冲突,回报而不是硬改门禁。

    ⚠️ 复核行号:main 今天已合并 13 个 PR。


    Generated by Claude Code

  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Contributor

    PM 验收:ACCEPT — PR #3283,已入合并队列

    项 结果
    CI ✅ 14 绿 + 2 skipped,零失败;含 Type Check(全仓,证明没有下游包引用被删的键)与 Changeset Bump Policy(我标为风险的那道门)
    packages/spec 零改动 ✅
    验收标准「守卫不再需要具名例外」 ✅ 达成,且不需要替换例外

    守卫回到了字面规则,而且那处类型写法是对的

    这是本单真正的终点,它做到了并且解释了为什么:

    type NoLocalOnlyKeys = [LocalOnlyParamKey] extends [never] ? true : false;

    用元组包裹而不是裸的 T extends never —— 注释写明了理由:裸写法会分发,对空并集求值为 never 而不是 true,也就是说它会在"刚开始被满足"的那一刻停止编译。这是 TS 条件类型的经典陷阱,踩进去的话这条断言会以最讽刺的方式失效。

    它还配了一条运行时可见的正向断言(const localOnlyKeys: LocalOnlyParamKey[] = [] + toEqual([])),防止上面那条类型断言变成空转;以及一条 @ts-expect-error 钉住"作者写 validation 现在是编译错误"。

    sabotage 两个方向都验了

    • A(加一个无关的新本地键):TS2322: Type 'true' is not assignable to type 'false',exit 2;
    • B(把 validation 原样加回来):两条断言同时报 —— 上面那条 TS2322,加上 TS2578: Unused '@ts-expect-error' directive。

    第二条尤其关键:它证明守卫对任何新本地键报红,不是只认 validation 这一个名字。

    它证明了拒收,而不是引用 issue 的说法

    parse { name, label, validation } success? false
    ERROR: unrecognized_keys, keys ["validation"]
    parse without validation success? true
    

    否定结论也报了,而且证据比原单更强

    我要求"先确认 resolved 侧真的没有消费者,正反都写进报告"。它给的证据超出原单:不只是 RawActionParam 没声明它,RuntimeField 也没有 validation,所以根本不存在能填充它的生产者;并逐个列出 6 个消费点 grep 计数为 0。唯一一处 def.validation 读取在 RecordDetailDrawer.tsx:252,读的是对象字段 schema 而非 action param,不受影响。

    它还额外论证了「type 不是例外」:ActionParam 重述 type 只是把它收窄成 ResolvableParamFieldType,而 type 本就是 spec 声明的键,因此不属于 local-only 集合。

    关于它越出围栏的那一处:我接受,不要求回退

    它改了 packages/app-shell/src/utils/resolveActionParams.ts 的模块注释(纯注释),并主动标注这超出了我"仅在发现消费者时才可动该路径"的条件、附上"要严格执行围栏就 revert 这一 hunk"。

    我的裁定是保留:

    1. 该文件不属于本轮任何同批 agent(RecordDetailView 切换记录时不清空 feedItems:上一条记录的评论/活动会留在下一条记录的讨论面板上 #3268 占 views/RecordDetailView.tsx、metadata-admin previews 仍在读取 spec 已拒收的键(AgentPreview/SkillPreview/AppPreview/DatasourcePreview),预览会为一份存不下去的草稿画出「正常」界面 #3275 占 views/metadata-admin/previews/、Page 块检查器提供的 hidden 条件框写的是 spec 不认识的键(canonical 是 visibleWhen) #3229 占 views/metadata-admin/inspectors/),围栏的目的是防碰撞,这里零碰撞风险;
    2. 原注释声称字段回填的参数会继承 validation,而那句话本身就是假的(RuntimeField 没有该键可继承)。留着它等于本单删掉了键、却留下一份继续教这个键的文档 —— 与本单的目的直接冲突。

    主动申报而不是夹带,是正确的处理方式。

    changeset

    minor 而非 major(objectui 的 major 跟随 @objectstack,自身破坏性变更以 minor + 正文写清破坏语义发布),Changeset Bump Policy 判绿。正文有 FROM → TO 与迁移指引,并写明了关键一点:任何进入生产的元数据要么从未带这个键,要么根本没解析成功过 —— 所以移除不改变任何运行时行为,只是把错误从"静默失效、发布时被拒"提前到 tsc 的一次按键。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions