Repository navigation
Commit 4c2cd29
fix(lint)!: publish refuses a text-vs-number comparison in a validation rule (or another fail-closed predicate) instead of warning, then refusing every write (#29923)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7J3TmFTYMYQrfpye6GQNg9)_
Fixes #29904
Clause-②: no (narrowing) — the author-time gate (`os build`, `os
validate`, the object save door and the package publish) refuses a
`type-mismatch` it used to pass with a warning, at the predicate sites
that fail closed. Nothing is widened.
Metadata-protocol: narrowed
Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx`
(the build-time validation table's type-soundness row said `warning` for
every site) and the MCP skill text in `packages/mcp/src/skill-md.ts`;
`skills/**` teaches no text-vs-number comparison and no type-soundness
severity (grep for the verdict words and `record.X > N` on text fields:
0 hits), so no skill or eval moves.
## 维护者速读
- 改了什么:校验规则(以及 requiredWhen / readonlyWhen、选项 visibleWhen、hook
条件)里"文本字段和数字比大小"(如 `record.name > 5`)现在发布时直接拒绝(error),公式字段仍只给 warning。
- 为什么改:这些位置求值失败会拒绝写入;以前发布只提示"会得到
null",发布成功后该对象的每次新建和更新都被拒,一个笔误就让整张表录不进数据。
- 风险与回滚:收窄发布门,已存在的此类规则下次发布会被拒(它们本来就让写入全部失败);回滚即 revert 本 PR,无数据迁移。
- 先看:`packages/formula/src/validate.ts`(fail-closed
分支与文案)、`packages/lint/src/validate-expressions.ts`(`FAIL_CLOSED`
站点表及各调用点)、`packages/mcp/src/mcp-http-tools.ts`(validation 站点)。
- 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run
src/publish-type-mismatch-fail-closed.test.ts`,预期 3 条全过:校验规则被拒、公式仍发布带
warning、可空孪生照旧被拒。
- 需要你做的:无,席位落地。
## What changes
- **`@objectstack/formula`.** `ExprSchemaHint` gains `faultOutcome?:
'fail-closed'`. The validator cannot tell a validation rule's
`predicate` from a flow condition's, so the caller that knows the site
declares it. With it, `type-mismatch` is graded an **error** whose
message says what really happens (the write, or the operation a hook
guards, is refused) and how to compare like with like; the refusal's
`params` carry `outcome: 'fail-closed'`. Without it, the warning and its
"evaluates to null" text are unchanged.
- **`@objectstack/lint`.** One constant, `FAIL_CLOSED`, with the site
ledger in its docblock, passed by `check()` at exactly the fail-closed
sites: validation rule `condition` / `when` and nested `then` /
`otherwise` rules, field `requiredWhen` / `readonlyWhen`, option
`visibleWhen`, hook `condition`. These are the sites where the
nullable-comparison twin is already an error with the `'fail-closed'`
outcome (plus `readonlyWhen` and option `visibleWhen`, see below).
- **`@objectstack/mcp`.** `validate_expression` with `site:
'validation'` passes the same outcome, so the tool an agent self-checks
with answers what publish will. Its description and the served skill
text say so.
- **`@objectstack/spec`.** Upgrade guide entry
`validation-type-mismatch-refused` (semantic: no mechanical rewrite).
ADR-0087 disposition `registered validation-type-mismatch-refused`.
- **Docs.** `content/docs/data-modeling/formulas.mdx`: the
type-soundness row's severity and fix.
### Which roles fail closed (measured by reading each evaluator)
| site | runtime on a fault | this PR |
|:--|:--|:--|
| validation rule `condition` / `when`, nested rules |
`rule-validator.ts` `checkPredicate` / `checkConditional`: "write
rejected" (#4649) | error |
| field `requiredWhen` | `rule-validator.ts`: refuses the write
(ADR-0137 D2) | error |
| field `readonlyWhen` | `isReadonlyWhenLocked`: throws
`ValidationError` on every fault but the unbound-root carve-out
(ADR-0137 D2) | error |
| option `visibleWhen` | `evaluateOptionVisibility`: refuses a write
that picks the option (ADR-0137 D2, #22402) | error |
| hook `condition` | `hook-wrappers.ts`: aborts the operation (#4775) |
error |
| formula field `expression` | reads null | warning (text true) |
| flow start / edge / `decision` condition | the run fails; the write
that fired it lands (`run-fails`) | warning, left out: not a write gate.
Open question in the report |
| field `visibleWhen` / `settledWhen`, `conditionalFormatting`, field
group `visibleWhen`, action `visible` / `disabled` / `pressed`, app
landing `visible` | renderer shows, hides or styles; never refuses a
write | warning |
| sharing rule `condition`; RLS / permission predicates | compiled to a
query filter (`compileCelToFilter` / `isSupportedRlsExpression`), not
CEL per row | not judged by this verdict |
The evaluator's fail-closed behaviour is untouched.
**The conversion the message names, measured on the evaluator**
(`ExpressionEngine.evaluate`, values `"abc"` / `"7"` / null):
`record.name > 5` → FAULT / true / FAULT; `size(record.name) > 5` →
false / false / FAULT; `double(record.name) > 5` → FAULT / true / FAULT
(validates clean, faults on non-numeric text, so the message says so);
`int(...)` same as `double`.
## Tests
- `packages/lint/src/validate-expressions.test.ts`: the old pin "warns
on a validation predicate ordering a text field against a number" is the
behaviour this card retires; it is replaced by five pins: the validation
`condition` refusal (path `objects[0].validations[0].condition`,
message), the formula warning, one refusal per other fail-closed site
(`when`, nested `then`, `requiredWhen`, `readonlyWhen` on a boolean,
option `visibleWhen`, hook `condition`), the warning kept on field
`visibleWhen` and action `visible`, and the nullable twin unchanged.
- `packages/formula/src/validate.test.ts`: `faultOutcome` → error with
`code`/`params`, boolean arm, and the two conversions validate clean.
- `packages/mcp/src/mcp-validate-expression.test.ts`: `site:
'validation'` → error.
- `packages/objectql/src/publish-type-mismatch-fail-closed.test.ts`
(new): the real protocol `saveMetaItem` (draft) + `publishPackageDrafts`
over a real ObjectQL: refused with `expression-invalid` / `error` at
`…validations[0].condition`; formula publishes with the warning
advisory; the nullable twin still refused.
<details><summary>Package suites (at d5b798c, under os-verify-lock;
the final commit 1dfad41 changes only the new objectql test's driver
double)</summary>
```
formula test Test Files 64 passed (64) Tests 1629 passed (1629)
lint test Test Files 192 passed (192) Tests 7949 passed (7949)
mcp test Test Files 55 passed (55) Tests 660 passed (660)
spec test Test Files 808 passed (808) Tests 24115 passed | 1 todo (24116)
spec test:repo Test Files 89 passed (89) Tests 1524 passed (1524)
objectql new file Tests 3 passed (3)
typecheck: formula, lint, mcp, spec, objectql — exit 0
```
</details>
<details><summary>Ablation (old severity put back in the fail-closed
branch, formula rebuilt, marker proven in dist)</summary>
Mutation: `severity: 'error'` → `severity: ['warning',
'ablated-29904'][0] as 'warning'` in the fail-closed arm;
`ablation-replace` anchor 1 → 0, `ablation-dist-preflight` marker
present in `dist/index.js` and `dist/index.mjs`.
```
lint × refuses a validation predicate ordering a required text field against a number (#29904)
AssertionError: expected { …(5) } to match object { severity: 'error', …(1) }
× refuses the type mismatch at every other fail-closed site (#29904)
Tests 2 failed | 3 passed
objectql × refuses `record.name > 5` on a required text field in validations[].condition
AssertionError: expected 'published' to be 'refused'
formula × errors on the same comparison where the caller declares the site fail-closed (#29904)
mcp × errors on a text field ordered against a number at a validation site (#29904)
```
The nullable-twin pins stayed green under the mutation (they are the
control). Restore: blob back to HEAD `6fcce907`, `git diff HEAD` empty,
formula rebuilt, preflight `--absent` clean on all 6 built files; lint
5/5 and objectql 3/3 green again.
</details>
<details><summary>Real boot (showcase `pnpm dev -- --fresh`, full build
at d5b798c, as admin, writable package com.qa.tmm)</summary>
```
[1] PUT /api/v1/meta/object/tmm_lead?mode=draft (required text `name`, rule `record.name > 5`) → 200
POST /api/v1/packages/com.qa.tmm/publish-drafts → 200 outcome=refused
rule=expression-invalid severity=error path=objects.tmm_lead.validations[0].condition
message: type mismatch `string > int` — `record.name` holds text but is used with `>` against a number.
CEL has no such operation, so wherever the value is not numeric text (an empty value included) the
expression faults, and here an expression that cannot be evaluated fails closed: the write (or the
operation a hook guards) is refused, not skipped. Compare like with like: … `size(record.name)` for its
length, `double(record.name)` for its value (which itself faults on text that is not a number).
[1b] same rule as `size(record.name) > 5` → publish 200 outcome=published
POST /api/v1/data/tmm_lead {"name":"abc"} → 201
POST /api/v1/data/tmm_lead {"name":"abcdefg"} → 400 VALIDATION_FAILED rule_violation "bad" (the rule now judges)
[2] formula `big = record.name > 5` → publish 200 published, advisory expression-invalid severity=warning "… silently evaluates to null …"
[4] nullable twin `record.qty > 1000` → publish 200 outcome=refused, expression-invalid error "… declares as nullable …" (unchanged)
```
Before (card, main `589049b0`): the same `[1]` published with a
`warning`, and every `POST /data` answered 400 "could not be evaluated …
write rejected". The draft save answers 200 for both this rule and the
twin, as before (the save door keeps #4463 D1).
</details>
## Acceptance notes
- **Validation rules of `severity: 'warning'` / `'info'`.** The runtime
logs an unevaluable advisory rule instead of refusing; the gate refuses
the comparison on every validation rule regardless, exactly as the
nullable twin does. Kept for parity with the twin; the message's
"refused" clause is exact for `error` rules.
- **`readonlyWhen` and option `visibleWhen`** are fail-closed sites the
twin does not cover (its `readonlyWhen` exclusion is ruling #4953 clause
3, about the `has()` prescription, which this verdict does not give).
Included because their evaluators refuse the write on a fault.
- **Serial check.** At 2026-11-03T11:40Z and again at 12:55Z (before
opening), none of the open PRs touches
`packages/formula/src/validate.ts`,
`packages/lint/src/validate-expressions.ts` or
`packages/mcp/src/mcp-http-tools.ts` (per-PR file lists read).
## Verification
All at the final head **1dfad411f**.
- `node scripts/pm/dispatch-gates.mjs --commands`: 106 derived families,
each run, all **exit 0**; `--ran` reconciliation: "106 derived, 106 run,
0 NOT-MEASURED, 0 UNRUN". The first round (at d5b798c) had one red,
`check:objectql-double-limit` (the new test's copied `find` double
ignored `limit`); fixed in 1dfad41, and the whole list was run again.
- `pnpm lint` (`eslint . --no-inline-config`): exit 0.
- Full dogfood (`pnpm --filter @objectstack/dogfood test`, after a full
`turbo run build`, 119/119 tasks): Test Files 410 passed | 3 skipped
(413), Tests 3379 passed | 28 skipped (3407); lock VERDICT command-exit
0.
- `check-adr-0087-registration --base origin/main`: "registered
validation-type-mismatch-refused (new here)".
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzhUVCv2vcJJepWyHQQ
<!-- signed: domain:server#6 · os-support-ai ·
session_01WGufzhUVCv2vcJJepWyHQQ -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 7b2bb66 commit 4c2cd29
12 files changed
Lines changed: 519 additions & 26 deletions
File tree
- .changeset
- content/docs/data-modeling
- packages
- formula/src
- lint/src
- mcp/src
- objectql/src
- spec/src/migrations/entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
242 | 242 | | |
243 | 243 | | |
244 | 244 | | |
245 | | - | |
| 245 | + | |
246 | 246 | | |
247 | 247 | | |
248 | 248 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
169 | 169 | | |
170 | 170 | | |
171 | 171 | | |
172 | | - | |
173 | | - | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
174 | 176 | | |
175 | 177 | | |
176 | 178 | | |
177 | 179 | | |
178 | 180 | | |
179 | 181 | | |
| 182 | + | |
180 | 183 | | |
181 | 184 | | |
182 | 185 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
764 | 764 | | |
765 | 765 | | |
766 | 766 | | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
767 | 788 | | |
768 | 789 | | |
769 | 790 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
222 | 222 | | |
223 | 223 | | |
224 | 224 | | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
225 | 242 | | |
226 | 243 | | |
227 | 244 | | |
| |||
308 | 325 | | |
309 | 326 | | |
310 | 327 | | |
311 | | - | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
312 | 331 | | |
313 | 332 | | |
314 | 333 | | |
| |||
319 | 338 | | |
320 | 339 | | |
321 | 340 | | |
| 341 | + | |
322 | 342 | | |
323 | 343 | | |
324 | 344 | | |
| |||
345 | 365 | | |
346 | 366 | | |
347 | 367 | | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
348 | 399 | | |
349 | 400 | | |
350 | 401 | | |
| |||
1208 | 1259 | | |
1209 | 1260 | | |
1210 | 1261 | | |
1211 | | - | |
| 1262 | + | |
| 1263 | + | |
1212 | 1264 | | |
1213 | 1265 | | |
1214 | | - | |
| 1266 | + | |
1215 | 1267 | | |
1216 | 1268 | | |
1217 | 1269 | | |
| |||
1246 | 1298 | | |
1247 | 1299 | | |
1248 | 1300 | | |
1249 | | - | |
| 1301 | + | |
1250 | 1302 | | |
1251 | 1303 | | |
1252 | 1304 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
872 | 872 | | |
873 | 873 | | |
874 | 874 | | |
875 | | - | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
876 | 884 | | |
877 | 885 | | |
878 | 886 | | |
879 | | - | |
| 887 | + | |
880 | 888 | | |
881 | 889 | | |
882 | 890 | | |
883 | | - | |
884 | | - | |
885 | | - | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
| 899 | + | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
| 915 | + | |
| 916 | + | |
| 917 | + | |
| 918 | + | |
| 919 | + | |
| 920 | + | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
| 976 | + | |
886 | 977 | | |
887 | 978 | | |
888 | 979 | | |
| |||
0 commit comments