Skip to content

fix(types,core): *Validation 五件套派生自 spec 17,引擎不再与服务端逐条相反 (#3103) - #3107

Merged
os-zhuang merged 1 commit into
mainfrom
claude/validation-spec-v2-0-1-6a7f3f
Jul 31, 2026
Merged

fix(types,core): *Validation 五件套派生自 spec 17,引擎不再与服务端逐条相反 (#3103)#3107
os-zhuang merged 1 commit into
mainfrom
claude/validation-spec-v2-0-1-6a7f3f

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3103. Ledger: objectstack#4115 120 → 115.

Step 0 — 引擎接线 triage(定级依据)

ObjectValidationEngine 在 objectui 内没有生产消费者。全仓 grep 只找到它自己的测试文件;console 的客户端校验路径不经过它。但它是 @object-ui/core已发布公共 API(src/index.tsvalidation/index.tsvalidators/index.ts),而且模块头明文宣称 "ObjectStack Spec v2.0.1 compliant"。

所以业务价值定级不是「线上正在坏」,而是:这是一个对外承诺 spec 合规、却与服务端逐条相反的 API。任何按注释接线它的宿主应用(或 agent)会拿到与服务端相反的判定。这反而把 Step 2 的力度调高了——没有 in-repo 调用方要兼容,所以直接收敛到 spec 语义,不留 back-compat 分支;风险全部落在「未来接线时静默分歧」,而那正是派生 + 闸门要挡的东西。

Step 1 — 逐个对照 spec 17 源(读 schema 源码,非注释)

对照 @objectstack/spec/src/data/validation.zod.ts(17.0.0-rc.0)与服务端权威实现 objectql/src/validation/rule-validator.ts

本地(旧) spec 17 性质
BaseValidation.events: ('insert'|'update'|'delete')[] ('insert'|'update')[],默认 ['insert','update'] delete 已作为已证实的 no-op 被退役(objectstack#3184)
BaseValidationpriority priority: int 0..9999 = 100(低者先跑) 缺键,执行顺序不受控
active: boolean / severity 必填 有 default,wire 上常缺席 旧引擎把缺席读成 falsy → 静默停用规则
ScriptValidation.condition: string ExpressionInput = string | {dialect,source} 形状漂移
CrossFieldValidation.condition: string 同上;且仅 fields[0] 被读(定位报错字段) 同上
StateMachineValidationinitialStates initialStates?: string[] 缺键(objectstack#3165),FSM 入口无法预检
ConditionalValidation: condition + rules[] when + then / otherwise 键名整体改名 + 少了 else 分支
FormatValidation: pattern + flags,8 个具名格式 regex,4 个具名格式(email/url/phone/json) 键名改名;多出的 5 个格式(ipv4/ipv6/uuid/iso_date/credit_card)spec union 根本不收
union 8 员:含 unique/async/range 6 员:含 json_schema,不含前三者 spec 有意移除:uniqueness→唯一索引(SELECT-then-INSERT 有 TOCTOU),async→表单层(SSRF/延迟);range spec 从来没有过

issue 里没料到的第 4 处实质漂移(最严重的一处)——谓词极性反了。
spec 的 .describe()、服务端模块头、以及 checkPredicate 实现三处一致:谓词为 TRUE ⇒ 规则被违反(if (result.value === true) return violation)。旧引擎是 if (!result) return invalid ——完全相反。也就是说 spec 写法的 script/cross_field 规则,客户端给出的是与服务端逐条相反的结论。

(附:spec 的 cross_field 文档示例 #1 自身与 .describe() 矛盾,写成了 assert-true。以实现为准;已在 PR 里记一笔,不阻塞。)

每条漂移的用户可见后果(全部按「客户端预检 vs 服务端权威」判定):

  • envelope conditionexpression.trim() 抛错 → catch 吞掉 → 旧极性下读作「通过」= 静默 no-op(用户失去客户端预检)。
  • conditionalrule.conditionundefined,同上 = 静默 no-op;otherwise 分支从未被求值。
  • formatrule.patternundefined,undefined.test() 抛错 → catch 里 return {valid:false} = 误报违规(客户端拦了服务端允许的写入)。
  • 缺席的 active → 规则被跳过 = 静默 no-op;缺席的 events.includes 抛 TypeError。

Step 2 — 按 #3090 判别法分流

判别问题是「同一概念的两个方言」还是「两层不同概念」。这里是前者,而且比 SelectOption 更彻底:这些规则就是对象 ObjectSchema.validations 里的那一份,服务端在写路径上评估,objectui 只是同一份规则的客户端预检。不存在独立的「UI 层校验规则」词汇。→ 派生 by reference

  • 五个 spec 同名类型改为 z.input<typeof Spec…Schema> 派生。
  • z.input 而非 z.infer:objectui 吃的是 /meta 上的作者态 JSON,尚未经过 spec 的 default 填充与 ExpressionInput 规范化。z.input 才是 JSON 里真实的形状——conditionstring | {dialect,source}(正是 issue 要的 wire 形状),active/events/priority/severity 可以缺席。
  • BaseValidation 也派生(Omit<ScriptValidation,'type'|'condition'>),spec 往 base 加键会自动流进来。
  • 一处 pinned divergence:ConditionalValidation.then/otherwise。spec 把 ValidationRuleSchema 标注成 z.ZodType<BaseValidationRuleShape>(带 [key:string]: unknown 的口袋类型),导致其 dist 里 then/otherwise 擦除成 unknown(objectstack#4171)。整体派生会把一个可判别联合换成 unknown —— 正是 DEBT 头部警告的「披着 burn-down 外衣的类型安全回退」。故只有这两个键改用 objectui 的精确联合,并配反向 pin:spec 哪天把它们标注对了,测试立刻红,提示退役这处分歧。
  • unique/async/range 与 spec 无同名冲突(不在台账里),保留但标 @deprecated,注明 spec 为何移除、真正的机制在哪(唯一索引 / 表单层 / 字段 min-max),并由测试证明 ValidationRuleSchema 确实拒收它们——所以注释是真的

引擎侧同步收敛到服务端语义:极性、.source coerce(fail-open)、when/then/otherwiseregex + 4 格式、initialStates、spec 默认值、priority 排序;不实现的类型(spec 的 json_schema)响亮 warn 而不是报「通过」。默认求值器仍不是 CEL,已明写并同时绑定 spec 的 record.x 与 objectui 历史的裸 x 作用域。

Step 3 — 注释治理

grep "Spec v2.0.1" packages/types/src/data-protocol.ts(16 处清零,含 6 处 window-function 的冒领)。core 校验簇(validation/index.tsvalidators/index.ts、引擎、引擎测试)与 types/src/index.ts 的校验导出块一并清理。canonicity 现在由 import/派生 + 闸门承载(#3017)。

留了两处未动,属于别的簇、动它们只会撑大 diff:types/src/ui-action.ts(6)、types/src/index.ts:868 的 UI-Actions 段头(1)、types/src/app.ts(2)、core/src/query/query-ast.ts(3)、spec-derived-unions.test.ts(1)。建议随各自簇处理(ActionSchema 已在 ALLOW 里)。

Step 4 — 台账与 mutation-test

台账 120 → 115(五条全部删除,无一转 ALLOW)。派生守卫在删除前会自己报「5 symbols in DEBT that no longer collide」,删除后 ✅。

闸门 mutation-test(逐个删机制,证明会红) —— 9 个变异,8 红:

变异 闸门 结果
M1 ConditionalValidation 派生改回手写 tsc -p tsconfig.test.json 🔴 TS1360
M2 FormatValidation 派生改回 pattern 手抄 同上 🔴 TS1360 + TS2353
M3 BaseValidation 改为手写列举 同上 🔴 TS1360 + TS2353(priority 消失)
M4 谓词极性改回 FALSE⇒违反 引擎测试 🔴 14 failed
M5 去掉 ExpressionInput coerce 引擎测试 🔴 2 failed
M6 去掉 initialStates 插入检查 引擎测试 🔴 1 failed
M7 去掉 spec 默认值(缺席 active 读作 falsy) 引擎测试 🔴 29 failed
M8 丢掉 otherwise 分支 引擎测试 🔴 1 failed
M9 去掉 field in record 守卫 引擎测试 🟢 不咬

M9 是如实记录的负面结果:objectui 校验的是记录快照,服务端校验的是 patch 负载,所以「字段缺席」在这里已经塌缩进「值为 undefined」,空值检查先接住了。该守卫保留是为了与 rule-validator.ts 结构一一对应(以及未来空值语义若改动时的防御),但不声称它是闸门——测试注释里写明了这一点,而不是留一条看起来受保护、实际没有的机制。

类型层派生只能由 tsc 守住(TS 类型运行时擦除,expect() 够不着)。这条通路是活的:packages/types/tsconfig.test.json 会编译测试文件(#3009 之前,spec-derived-unions.test.ts 里同样形状的一整块 satisfies 终其一生都是装饰品)。

验收标准

  • 台账 -5(120 → 115),无一转 ALLOW
  • 引擎对 envelope condition 的行为有测试钉住(静默 no-op → 正确评估;不可评估的 envelope → fail-open + warn)
  • grep "Spec v2\.0\.1" packages/types/src/data-protocol.ts 为空

验证

vitest  packages/types/src/__tests__ + packages/core/src/validation  →  25 files / 370 tests 全绿
turbo   type-check --filter=@object-ui/types --filter=@object-ui/core →  绿
eslint  改动文件 --quiet                                              →  0 error
node    scripts/check-spec-symbol-derivation.mjs                      →  ✅ 115

破坏面

@object-ui/types / @object-ui/core 双 minor(按 AGENTS.md §9:objectui 的破坏性变更也标 minor,major 跟随 @objectstack)。规则类型的键改了(condition+rules[]when+then/otherwise;patternregex),引擎极性反转,validateRecordevent 不再收 'delete'。仓内零消费者(只有 barrel 再导出),对外的宿主应用需要按上表迁移——changeset 正文里有完整迁移表。

关联:objectstack#4115(台账)、#3090(判别法)、objectstack#4171(spec 类型擦除,本 PR 的 pinned divergence 依赖它)、objectstack#3165 / #3184(spec 侧的加键与退役)。后继 #3104

…engine stops disagreeing with the server (#3103)

Five rule types in `data-protocol.ts` were hand-written interfaces labelled
`(ObjectStack Spec v2.0.1)` while the installed spec was 17.0.0-rc.0. Nothing
bound them to the spec, so fifteen majors of drift accumulated with `tsc` silent
and the comment still vouching for it — the planted-premise failure class
objectstack#4115 was filed for.

They are now `z.input` derivations of the spec's schemas. `z.input`, not
`z.infer`: objectui consumes AUTHORED metadata off `/meta`, before the spec's
defaults and ExpressionInput canonicalization run, so `condition` really is
`string | {dialect, source}` and `active`/`events`/`priority` really can be
absent.

What the copies had drifted into, and what each cost the user — the engine is a
client PRE-CHECK of rules `objectql/src/validation/rule-validator.ts` enforces,
so every one of these is a disagreement with the authority:

  - Predicate POLARITY was inverted. The server violates a rule when the
    predicate is TRUE; the engine violated it when FALSE. Every spec-authored
    `script`/`cross_field` rule got the opposite verdict.
  - `conditional` read `condition` + `rules[]` where the spec says `when` +
    `then`/`otherwise` → silent no-op, and `otherwise` never ran at all.
  - `format` read `pattern` where the spec says `regex` → `undefined.test()`
    threw into a catch that reported a VIOLATION, blocking writes the server
    accepts.
  - Envelope-valued conditions hit `expression.trim()`, threw, and read as
    "passes" → silent no-op.
  - An absent `active` disabled the rule; an absent `events` threw. Both arrive
    absent because the spec defaults them at parse time.
  - `initialStates` (objectstack#3165) and `priority` were missing entirely;
    `events: 'delete'` was still advertised after objectstack#3184 retired it.

`unique`/`async`/`range` have no spec counterpart — the spec removed the first
two deliberately (uniqueness → a unique index, since SELECT-then-INSERT is racy;
async → the form layer) — and are now @deprecated, with a test proving the
spec's union rejects them, so the comment is load-bearing rather than decorative.

Gates, each mutation-tested: the compile-time pins in
`validation-rule-spec-parity.test.ts` (live because tsconfig.test.json compiles
it, #3009) go TS1360 when a derivation is reverted, and the engine suite goes red
for polarity, envelope coercion, `otherwise`, `initialStates` and the spec
defaults. `ConditionalValidation.then/otherwise` carry one pinned divergence —
the spec's published types erase them to `unknown` (objectstack#4171) — with an
inverted pin that fails the day upstream fixes it.

Canonicity now rides on the import and the gate, not the comment: `grep
"Spec v2.0.1" packages/types/src/data-protocol.ts` is empty, and objectstack#4115's
ledger drops 120 -> 115.

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)
objectui Ignored Ignored Jul 31, 2026 8:33am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-BOl6My3k.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.26KB 2.99KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.12KB 3.41KB
auth (LoginForm.js) 17.86KB 5.29KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.43KB 2.09KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 474.19KB 103.88KB
core (index.js) 2.20KB 0.79KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.34KB 34.64KB
fields (index.js) 222.06KB 54.35KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 25.17KB 5.80KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.44KB 10.66KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.90KB 12.35KB
plugin-charts (index.js) 60.52KB 17.11KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 111.59KB 28.74KB
plugin-designer (index.js) 210.51KB 42.50KB
plugin-detail (index.js) 221.90KB 54.29KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.38KB 26.93KB
plugin-gantt (index.js) 162.26KB 39.53KB
plugin-grid (index.js) 182.21KB 48.24KB
plugin-kanban (index.js) 47.82KB 13.18KB
plugin-list (index.js) 104.87KB 25.18KB
plugin-map (index.js) 16.80KB 5.24KB
plugin-markdown (index.js) 13.65KB 4.67KB
plugin-report (index.js) 40.32KB 10.53KB
plugin-timeline (index.js) 25.75KB 7.32KB
plugin-tree (index.js) 8.36KB 2.81KB
plugin-view (index.js) 83.54KB 20.39KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 3.47KB 1.54KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.45KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 1.08KB 0.64KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang merged commit 912496d into main Jul 31, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/validation-spec-v2-0-1-6a7f3f branch July 31, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

*Validation 五件套:注释冒领 Spec v2.0.1、condition 形状已漂移——对照 spec 17 triage + 派生/闸门(objectstack#4115)

1 participant