Repository navigation
Commit 9037b13
fix(lint)!: a type mismatch in a record-triggered flow condition or an advisory validation rule is served a verdict that says what really happens (#29953)
<!-- 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 #29926
## 维护者速读
- **改了什么**:文本或布尔字段和数字比大小(如 `name >
5`)写在「记录触发流程」的开始条件或连线条件里,现在发布时直接拒绝(error),文案说明真实后果:流程运行在该条件处失败,触发它的写入照常落库。写在建议性校验规则(`severity:
'warning' | 'info'`)里仍被拒,但两条判词(类型不匹配和可空比较)都改成如实说明:规则被跳过、写入照常进行、建议永远不会出现。
- **为什么改**:#29923 之后这两个角色拿到的判词是假的:流程条件说「静默得到
null」,实际是运行失败;建议性规则说「写入会被拒」,实际写入照常进行(#24782)。另外 `type-mismatch`
一个代码对应两句话,违反「一个代码一句模板」的契约,现在每种结局各有自己的代码。
-
**风险与回滚**:收窄发布门:已存在的记录触发流程若条件里有这种比较,下次发布会被拒(它们本来每次运行都会失败);`type-mismatch`
带 `outcome: 'fail-closed'` 的那一支改名为 `type-mismatch-fail-closed`,并去掉
`outcome` 参数。这一支已随 18.3.0 发出(`@objectstack/formula@18.3.0` 的类型和
`@objectstack/mcp@18.3.0` 的 `validate_expression` 都在用,但 18.3.0 的
changelog 里没有记录),所以按 18.3.0 的 `type-mismatch` + `outcome`
识别它的使用方需要改认新代码。回滚即 revert 本 PR,无数据迁移。
- **先看**:`packages/lint/src/validate-expressions.ts`(`faultOutcomeOf`
账本,两条判词共用)、`packages/formula/src/validate.ts`(`TYPE_MISMATCH_FAULT_ARMS`:每种结局的代码和文案)。
- **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run
src/publish-type-mismatch-fail-closed.test.ts`,预期 5 条全过:校验规则被拒、公式仍发布带
warning、可空孪生照旧被拒、建议性规则被拒但文案不说写入被拒、记录触发流程条件被拒且文案说运行失败。
- **需要你做的**:无,席位落地(收窄,席位在落地前做 contract review)。
Clause-②: no (narrowing) — the author-time gate refuses, in a
record-triggered flow's start or edge condition, a `type-mismatch` it
used to pass with a warning; and the fail-closed arm of the verdict is
served under its own code `type-mismatch-fail-closed` instead of
`type-mismatch` + `params.outcome`. Added beside them (not counted as
the arm): the codes `type-mismatch-run-fails` and
`type-mismatch-fail-open`, and the exported type `ExprFaultOutcome` from
`@objectstack/formula`.
Metadata-protocol: narrowed
Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx`
(the type-soundness row names each outcome) and the MCP skill text in
`packages/mcp/src/skill-md.ts` (`flow_condition` now errors too).
`skills/**` teaches no text-vs-number comparison in a flow condition or
an advisory rule (grep for `type mismatch`, `evaluates to null` and the
flow condition examples), so no skill or eval moves.
## What changes
**One ledger for both verdicts (`@objectstack/lint`).** `FAIL_CLOSED`
becomes `faultOutcomeOf(site)`, the one function every call site asks
what a site's runtime does on a fault. Its docblock carries the ledger,
read off each evaluator. The answer goes to the shared validator as
`ExprSchemaHint.faultOutcome` (the type-mismatch verdict) and to
`checkNullGuards` as its `NullGuardOutcome` (the nullable-comparison
verdict). The tokens are the same three, so one rule cannot get two
different stories.
| site | runtime on a fault | outcome | type-mismatch before → now |
|:--|:--|:--|:--|
| validation rule `condition` / `when` / nested branches, `severity:
'error'` | write rejected (#4649) | `fail-closed` | error → error (code
renamed) |
| the same, `severity: 'warning'` / `'info'` (top-level rule's severity;
branches inherit it) | logged, "write proceeded: the rule is advisory"
(#24782) | `fail-open` | error "the write is refused" → error, true
wording |
| field `requiredWhen` / `readonlyWhen`, option `visibleWhen`, hook
`condition` | refuses the write / aborts the operation | `fail-closed` |
unchanged |
| record-triggered flow: start node `condition`, edge `condition` |
`evaluateCondition` throws; the run fails, the write lands | `run-fails`
| **warning "evaluates to null" → error** |
| any other flow; a non-start node's (inert) `condition` | — | none |
warning, unchanged |
| formula, display predicates | reads null / never refuses | none |
warning, unchanged |
**Item 1, flow conditions.** The twin refuses there
(`checkNullGuards(..., 'run-fails')`, severity `error`, #29026), so the
type mismatch does too. Its message: "… here a flow condition that
cannot be evaluated fails the run: the flow stops at this condition, the
write that fired it still lands, and the work the flow exists to do is
not done." It applies on exactly the twin's sites: the top-level start
node and every edge of a `record-*` flow. The twin's `decision` branch
row has no type-mismatch counterpart: the branch slot is validated
without the object's field types (`checkDeclaredPredicate`), so no type
mismatch reaches it, before or after.
**Item 2, advisory validation rules.** Measured on the twin: it refuses
every validation rule regardless of `severity`, with "the write is
rejected fail-closed". So the type mismatch keeps the refusal (`error`)
and both verdicts now word it as `fail-open`. The twin's `'fail-open'`
outcome was unused (its clause still described the pre-ADR-0137
`requiredWhen`), so its clause is reworded for the surface that really
skips: "so the rule is SKIPPED fail-open: it is advisory, so the write
proceeds, the rule is never applied (its advisory never fires), and the
only trace is a log line saying it could not be evaluated". The
type-mismatch arm says the same. Neither claims a refused write.
**Item 3, refusal codes (`@objectstack/formula`).** I took the "own code
per arm" option. `type-mismatch` (warning, reads null),
`type-mismatch-fail-closed`, `type-mismatch-run-fails` and
`type-mismatch-fail-open` (errors) each name one template, chosen from a
table keyed by the outcome (`TYPE_MISMATCH_FAULT_ARMS`), so a new
outcome without its own code does not compile. The `outcome` param is
gone. The fail-closed sentence is byte-identical to #29923's (the new
pin's message is that text). Pinned in `PINS` (four new cases), and
`SWEEP_SCHEMAS` gains the deal schema once per outcome, so the sweep
reaches every code and holds its params to its message.
Readers of the renamed code:
- MCP `validate_expression` passes the code through unchanged. Its
`site: 'validation'` now answers `type-mismatch-fail-closed`, and `site:
'flow_condition'` answers `type-mismatch-run-fails` as an error. Fields
bound bare is the record-triggered shape, and the gate refuses there.
Its description and the served skill text say so.
- Studio: no reader in this repo. `git grep` for `type-mismatch` and
`EXPRESSION_REFUSAL_CODES` outside `packages/formula` finds only the MCP
test and unrelated `sdui-parser` codes. A Studio catalogue in objectui
keyed to `type-mismatch` + `outcome` would need the new code.
- ~~The `type-mismatch` + `outcome` arm has not shipped in a release:
the #29904 changeset is still pending in `.changeset/`.~~ [Edit, rework
r1: false. `@objectstack/formula@18.3.0` (npm, 2026-11-03T15:09Z) ships
`outcome: 'fail-closed'` in `ExpressionRefusalParams`, and
`@objectstack/mcp@18.3.0` serves it on the `validation` site. The #29904
changeset is still pending only because version-packages #26517 branched
before `4c2cd29a`, so 18.3.0's changelog has no line for it. This PR
renames a served, released code: an 18.3.0 consumer keyed to
`type-mismatch` + `params.outcome === 'fail-closed'` moves to
`type-mismatch-fail-closed` (FROM → TO in the changeset).]
- Docs: `formulas.mdx` row (above).
**Item 4.** `18.validation-type-mismatch-refused` `acceptanceCriteria`:
"answers 200" → "answers 201".
**`@objectstack/spec`.** New semantic entry
`18.flow-condition-type-mismatch-refused`, registered as the ADR-0087
disposition. The changeset says what 18.3.0 already serves (the
`type-mismatch` + `outcome` arm, shipped without a changelog line) and
what this release changes about it, and that the #29904 changeset's
"flow conditions keep the warning" no longer holds.
## Why
Measured on `main` at `21510bba` (after #29923 `4c2cd29a`), through the
real `saveMetaItem` (draft) + `publishPackageDrafts` door. This is the
objectql test below, run with the ledger ablated back to #29923's
behaviour (ablation A5):
```
record-triggered flow, start condition `name > 5` on a required text field
→ outcome "published", advisory expression-invalid severity=warning:
"type mismatch `string > int` — `name` holds text … This faults at runtime, so the expression
silently evaluates to null (unless the value happens to be numeric)…"
advisory rule (severity 'warning') `record.name > 5`
→ refused, "… here an expression that cannot be evaluated fails closed: the write (or the
operation a hook guards) is refused, not skipped."
```
Both are false for that role. The runtime readings: `evaluateCondition`
throws on a fault (ADR-0032 §1c), and the record-change trigger isolates
the write. `evaluateValidationRules` runs an advisory rule with
`advisory: true` and logs `unevaluableAdvisoryMessage` ("write
proceeded: the rule is advisory, so it was not applied to this write").
## Tests
- `packages/formula/src/validate.test.ts`: fail-closed now asserts code
`type-mismatch-fail-closed` with exact params (no `outcome`). Two new
tests cover `run-fails` (flattened, bare `name`, conversions validate
clean) and `fail-open`.
- `packages/formula/src/expression-refusal-codes.test.ts`: `PINS` for
the three codes (exact params and message, a boolean case on
fail-closed). `SWEEP_SCHEMAS` adds one schema per outcome.
- `packages/lint/src/validate-expressions.test.ts`:
- record-triggered start + edge: `error` with paths and the run-fails
text;
- a flow with no `triggerType` and a `schedule` flow keep the warning on
the start and the edge condition; an inert node condition of a
record-triggered flow keeps it too (rework r1);
- advisory rule (`warning` and `info`, a nested branch included):
`error` with the fail-open text;
- twin agreement on one advisory rule and one `error` rule;
- the #5017 key meta-test tables `rule.severity` and the `site` plumbing
receiver.
- `packages/mcp/src/mcp-validate-expression.test.ts`: `validation` →
`type-mismatch-fail-closed`; `flow_condition` →
`type-mismatch-run-fails` error (was asserted a warning).
- `packages/objectql/src/publish-type-mismatch-fail-closed.test.ts`: two
new door cases, the advisory rule and the record-triggered flow.
<details><summary>Suites (at the final head, under os-verify-lock,
shared box)</summary>
```
formula test Test Files 64 passed (64) Tests 1634 passed (1634)
lint test Test Files 192 passed (192) Tests 7953 passed (7953)
mcp test Test Files 55 passed (55) Tests 660 passed (660)
spec test Test Files 808 passed (808) Tests 24277 passed | 1 todo (24278)
spec test:repo Test Files 89 passed (89) Tests 1524 passed (1524)
objectql publish-type-mismatch-fail-closed.test.ts Tests 5 passed (5)
typecheck + check:test-typecheck: formula, lint, mcp, objectql — exit 0; spec typecheck — exit 0
(spec typecheck OOM'd once at the default heap on the shared box; exit 0 with --max-old-space-size=8192.
objectql's test typecheck caught 4 errors in the new flow case — `raw` untyped — fixed in the final head.)
```
</details>
<details><summary>Ablations (each restored: blob == HEAD, git diff HEAD
empty)</summary>
```
A1 lint: record-triggered flag forced false (`recordTriggered: false /* ablated-29926-a1 */`)
× refuses a type mismatch in a record-triggered flow condition with the run-fails wording (#29926)
A2 lint: validation rule outcome forced `fail-closed`
× refuses a type mismatch in an advisory validation rule without claiming the write is refused (#29926)
× gives the nullable twin the same outcome on one advisory rule, and keeps fail-closed on an error rule (#29926)
AssertionError: expected 'type mismatch `string > int` — `recor…' to match /advisory/
A3 formula: every outcome arm served as `type-mismatch` again
× type-mismatch-fail-closed · × type-mismatch-run-fails · × type-mismatch-fail-open (PINS)
× every refusal validateExpression reports on the sweep carries a code from its set … (SWEEP_SCHEMAS)
× 3 validate.test.ts outcome tests — Tests 7 failed | 160 passed (167)
A4 mcp: `faultOutcome: 'run-fails'` removed from the flow_condition site
× validates a flattened flow condition … — expected true to be false (ok)
A5 objectql through the built lint dist (marker `ablated-29926-a5` proven in dist/index.js, index.cjs,
runtime.js, runtime.cjs): validation rules forced `fail-closed`, flow conditions no outcome
× refuses the comparison in an advisory rule … — message says "is refused, not skipped"
× refuses the comparison in a record-triggered flow condition … — outcome "published",
advisory severity=warning "… silently evaluates to null …" (the card's premise, reproduced)
Tests 2 failed | 3 passed (5); the #29904 cases stayed green (control)
Restore: each via ablation-replace (blob == HEAD, git diff HEAD empty); lint dist rebuilt clean by the
full build afterwards.
```
</details>
## Acceptance notes
- **Flows that are not record-triggered** keep the "evaluates to null"
warning. Their condition also faults the run, so that text is not true
there either. The card and order scope item 1 to record-triggered flows,
where the twin's grading exists; the other flows have no twin grading to
follow. Left as a reading for the seat.
- **`record.x` in a flow condition is not judged for type soundness** at
all: the flattened-scope check types bare names only, so `record.title >
5` in an edge draws no verdict, before or after (the new tests use the
bare spelling). Pre-existing, out of this card.
- **Advisory rules stay refused**, as the twin refuses them. The triage
audit reads that refusal as a publish "it should allow"; the order says
to follow the twin. Exempting both verdicts together would be a widening
for a separate ruling.
- **Serial check.** No open PR references #29898. Open PRs touching
`packages/lint/src/validate-expressions.ts`,
`packages/lint/src/validate-null-guards.ts`,
`packages/formula/src/validate.ts` or
`packages/mcp/src/mcp-http-tools.ts`: none of the 18 open PRs at
2026-11-03T17:12Z (the nearest, #29948, touches objectql /
metadata-protocol / runtime only; #29938 is the release notes).
## Verification
Rework r1 (head `06abf5ac`, changeset text + one lint test):
`check-adr-0087-registration --base origin/main` and `--self-test`,
`check-changeset-no-major --base origin/main`, `check-empty-changeset
--base origin/main` and `--self-test`,
`check:changeset-gate-self-tests`: exit 0.
`validate-expressions.test.ts` 459/459, lint `check:test-typecheck` and
ESLint on the file: exit 0. `dispatch-gates --commands` derives the same
106-family list as before. Everything below was measured at `61a4cdbf`,
which differs only in those two files.
- `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". Four exited 3 on the first pass for missing
prerequisites (`check-plugin-teardown-shape --self-test` on a shallow
clone; `check:skill-examples`, `check:dual-build-cjs-loads` and
`check:lean-entry-closure` before a full build). They were run again
after `git fetch --unshallow` and a full `turbo run build` (119/119
tasks): exit 0. The final head `61a4cdbf` differs from the swept commit
only in the new objectql test's type annotation (and the commit
message). `check:objectql-double-limit` and
`check:engine-double-contract` were run again on it: exit 0.
- `check-adr-0087-registration --base origin/main`: "registered
flow-condition-type-mismatch-refused (new here)".
- `pnpm lint` (`eslint . --no-inline-config`): exit 0.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzfu5kYQW45XjsF79Rp
<!-- signed: domain:server#6 · os-support-ai ·
session_01WGufzfu5kYQW45XjsF79Rp -->
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>1 parent a7293f7 commit 9037b13
16 files changed
Lines changed: 581 additions & 102 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 | + | |
| 25 | + | |
| 26 | + | |
| 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 | |
|---|---|---|---|
| |||
371 | 371 | | |
372 | 372 | | |
373 | 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 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
374 | 430 | | |
375 | 431 | | |
376 | 432 | | |
| |||
546 | 602 | | |
547 | 603 | | |
548 | 604 | | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
549 | 614 | | |
550 | 615 | | |
551 | 616 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
60 | 72 | | |
61 | 73 | | |
62 | 74 | | |
| |||
169 | 181 | | |
170 | 182 | | |
171 | 183 | | |
172 | | - | |
173 | | - | |
174 | | - | |
175 | | - | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
176 | 187 | | |
177 | | - | |
178 | | - | |
179 | | - | |
180 | | - | |
181 | | - | |
182 | | - | |
183 | | - | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
184 | 208 | | |
185 | 209 | | |
186 | 210 | | |
| |||
243 | 267 | | |
244 | 268 | | |
245 | 269 | | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
246 | 273 | | |
247 | 274 | | |
248 | 275 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
165 | 165 | | |
166 | 166 | | |
167 | 167 | | |
168 | | - | |
| 168 | + | |
169 | 169 | | |
170 | 170 | | |
171 | 171 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
771 | 771 | | |
772 | 772 | | |
773 | 773 | | |
774 | | - | |
775 | | - | |
776 | | - | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
777 | 780 | | |
778 | 781 | | |
779 | 782 | | |
780 | | - | |
| 783 | + | |
781 | 784 | | |
782 | 785 | | |
783 | 786 | | |
784 | 787 | | |
785 | 788 | | |
786 | 789 | | |
787 | 790 | | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
788 | 818 | | |
789 | 819 | | |
790 | 820 | | |
| |||
0 commit comments