Repository navigation
Commit 67d5d53
fix(formula,lint,mcp): a fail-closed date verdict says the write is refused, and a time-relative sweep's run-fails verdict names no firing write (#30492)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgEoPXTMomyUQtdV7LPhNsWK)_
domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb
## 维护者速读
- **改了什么**:两处判词改成说真话。一是失败即拒的校验规则(及字段 requiredWhen/readonlyWhen、选项
visibleWhen、hook 条件)里对日期做加减,判词不再说「字段静默得到 null」,改说「写入被拒」,新码
`date-arithmetic-fail-closed` /
`date-text-arithmetic-fail-closed`。二是定时相对扫描流程的条件里文本和数字比大小,判词不再说「触发它的写入照常落库」(扫描没有写入),改说「这一行的运行失败,别的什么都不发生」,新码
`type-mismatch-sweep-run-fails`。
- **为什么改**:两句话说的都是该位置不会发生的事:校验规则出错会拒写,不会得到 null;扫描由定时任务逐行启动,不是写入触发的。
- **风险与回滚**:分级不变,发布门不收也不放,只换文案和码。按旧码 `date-arithmetic` /
`date-text-arithmetic` 识别失败即拒站点、按 `type-mismatch-run-fails`
识别扫描流程的使用方需改认新码。公式字段、advisory 校验规则、记录触发流程原码原句不动。回滚即 revert,无数据迁移。
- **先看**:`packages/formula/src/validate.ts`(`dateArm` / `dateArmFault` 和
`TYPE_MISMATCH_SWEEP_ARM`)、`packages/lint/src/validate-expressions.ts`(`FlowBareNames.timeRelativeSweep`)、`packages/mcp/src/mcp-http-tools.ts`(一行传键)。
- **两分钟验证**:`pnpm --filter @objectstack/lint exec vitest run
src/validate-expressions.test.ts -t 30250`,预期 2 条通过:扫描流程判词不含 still
lands,记录触发对照仍含;失败即拒规则的日期判词说写入被拒,公式字段对照仍说 null。
- **需要你做的**:无,席位落地。
Fixes #30250
Clause-②: yes (widening) — three codes join `@objectstack/formula`'s
exported `ExpressionRefusalCode` / `EXPRESSION_REFUSAL_CODES`
(`date-arithmetic-fail-closed`, `date-text-arithmetic-fail-closed`,
`type-mismatch-sweep-run-fails`) and the optional key
`timeRelativeSweep` joins the exported `ExprSchemaHint`. A fail-closed
site's date verdicts move to the first two from `date-arithmetic` /
`date-text-arithmetic`, and a time-relative sweep's type-mismatch
verdict moves to the third from `type-mismatch-run-fails` (a consumer
keyed to the old codes at those sites re-keys). Grading is unchanged at
every site; what is refused, warned or passed is unchanged.
Metadata-protocol: none — what an author can write is unchanged, and `os
validate` reports no expression refusal code: its findings come from
`@objectstack/lint`'s `ExprIssue` (`where`, `message`, `source`,
`severity`, `path`), which carries no `code`. Only the message text
changes there.
Skills-docs: none — `skills/**` grep for `still lands` and `silently
evaluates to null` hits `skills/objectstack-formula/SKILL.md:407` and
`skills/objectstack-formula/evals/cel-predicates-formulas.json:87`, both
about a record-triggered flow's null-guard edge, which still lands the
write, so both stay true. The one hand-written doc sentence this PR
makes false is fixed here: `content/docs/data-modeling/formulas.mdx`
(type-soundness row).
## What changes
Measured on `main` at `c5800818` before the change, and again after
`origin/main` was merged.
**How each site learns its scope.** (1) A fail-closed site already says
so: `@objectstack/lint`'s `faultOutcomeOf` ledger and MCP
`validate_expression` (site `validation`) pass `faultOutcome:
'fail-closed'` at a `record`-scoped site, and a formula field passes
none. So the date verdicts read it with no new signal. (2) A
time-relative sweep did not say so. The ledger answers `run-fails` for a
record-triggered flow and for a sweep of the typed object alike, so
`@objectstack/formula` could not tell them apart. The new optional
`ExprSchemaHint.timeRelativeSweep` carries that one fact. It is read
only under `faultOutcome: 'run-fails'` at a `'flattened'` site.
| site | before | now |
|:--|:--|:--|
| fail-closed `record` site (`error` validation rule, field
`requiredWhen` / `readonlyWhen`, option `visibleWhen`, hook
`condition`): date arithmetic | `date-arithmetic` /
`date-text-arithmetic`, error, "this faults at runtime, so the field
silently evaluates to null." | `date-arithmetic-fail-closed` /
`date-text-arithmetic-fail-closed`, error: "this faults at runtime, and
here an expression that cannot be evaluated fails closed: the write (or
the operation a hook guards) is refused, not skipped (it does not read
null)." |
| formula field, display predicate, advisory (`warning` / `info`)
validation rule: date arithmetic | `date-arithmetic` /
`date-text-arithmetic` | unchanged, code and sentence |
| time-relative sweep of the typed object (`run-fails`): text or boolean
against a number | `type-mismatch-run-fails`, error, "… the flow stops
at this condition, the write that fired it still lands, and the work the
flow exists to do is not done." | `type-mismatch-sweep-run-fails`,
error: "… a flow condition that cannot be evaluated fails the run: the
time-relative sweep starts one run per row it finds, the run for this
row stops at this condition and the work the flow exists to do is not
done for that row, and nothing else happens (the sweep logs the failure
and goes on to the next row)." |
| record-triggered flow (`run-fails`) | `type-mismatch-run-fails` |
unchanged, code and sentence |
**Why new codes.** A code names one message template
(`expression-refusal.ts`, the contract), and each new sentence is a new
template, as #30015 and #30016 did. The sweep sentence is read off
`packages/triggers/trigger-schedule/src/time-relative-trigger.ts`: each
matched row is launched with `{ record: row, event: 'time_relative' }`,
and a failed run is caught per row, logged at `error` and counted, and
the sweep goes on.
**The nullable-comparison sentence (`validate-null-guards.ts`
`OUTCOME_CLAUSE['run-fails']`) is unchanged.** It is never served on a
sweep: `validate-expressions.ts` runs the null-guard twin only where
`triggerKind === 'record_change'` (`nullGuardObject`), and no other
caller passes `run-fails` to `checkNullGuards` / `nullGuardMessage`. On
a record-triggered flow it is true. The new lint pin asserts that no
finding on a sweep says "still lands", and that the record-triggered
control still does.
**Routing.** `@objectstack/lint`: `FlowBareNames` gains
`timeRelativeSweep` (`triggerKind === 'time_relative'`), handed to the
validator at every flow site that already gets the bare-name facts
(start, edge, `decision`, inert node conditions). `@objectstack/mcp`:
`validate_expression` with `site: 'flow_condition'` and `triggerKind:
'time_relative'` passes the same key, so the tool's answer stays the
gate's (the gate-parity test compares messages). The date verdicts'
`-run-fails` sentence already names no write, so it is served on a sweep
as it is.
## Tests
- `packages/formula/src/validate.test.ts`:
- new: a fail-closed `record` site's `record.start_date + 1 > 3` and
`record.end_date - record.start_date > duration("24h")` get the
`-fail-closed` codes and the refused-write sentence, and not "evaluates
to null". Controls: a formula field (`value`, no outcome) and an
advisory rule (`fail-open`) keep `date-arithmetic` /
`date-text-arithmetic` and the null sentence;
- new: `name > 5` and `record.name > 5` under `run-fails` +
`timeRelativeSweep` get `type-mismatch-sweep-run-fails`, the per-row
sentence, and no "still lands" / "write that fired". With no outcome the
key changes nothing (`type-mismatch-run-may-fail`). A sweep's date
verdict stays `date-arithmetic-run-fails`;
- changed: the #30016 test's last control asserted that a fail-closed
rule says "silently evaluates to null", which is the defect. It now
asserts that a formula field keeps that sentence.
- unchanged: `errors with the run-fails wording where the caller
declares a flow condition (#29926)` (the record-triggered "still lands"
pin, `:801` on `main`).
- `packages/formula/src/expression-refusal-codes.test.ts`: a `PINS`
entry for each new code (exact params and message). `SWEEP_SCHEMAS`
gains a fail-closed `record` schema with a date field and a flattened
`run-fails` + `timeRelativeSweep` schema, so the sweep reaches all three
codes and holds their params to their messages.
- `packages/lint/src/validate-expressions.test.ts`:
- new: a time-relative sweep's start condition `title > 5` gets the
per-row sentence, and no finding on the flow says "still lands" (nor
does the null-guard twin, which is not served there). Control: the same
flow record-triggered keeps "the write that fired it still lands" on the
type verdict and on the null-guard verdict of its edge;
- new (replaces the `#30016 control`, which pinned the null sentence on
a fail-closed rule): two `error` validation rules with date arithmetic
say the write is refused, not null. Control: a formula field's
`record.close_date + 1` keeps the null sentence;
- changed: the #29959 / #30026 time-relative test asserted "the write
that fired it still lands" on the sweep's three errors. It now asserts
the per-row sentence and the absence of "still lands" / "write that
fired".
- unchanged: every record-triggered "still lands" pin.
- `packages/mcp/src/mcp-validate-expression.test.ts`: unchanged. Its
gate-parity rows for `triggerKind: 'time_relative'` compare the tool's
messages with the gate's, so they pin the MCP half (ablation A2 below).
- `packages/objectql/src/publish-type-mismatch-fail-closed.test.ts`:
unchanged, 12/12 passed.
<details><summary>Suites (head fc42e4a, under os-verify-lock, shared
box)</summary>
```
formula test Test Files 64 passed (64) Tests 1649 passed (1649)
lint test Test Files 195 passed (195) Tests 8034 passed (8034)
mcp test Test Files 56 passed (56) Tests 678 passed (678)
objectql src/publish-type-mismatch-fail-closed.test.ts Tests 12 passed (12)
typecheck (tsc --noEmit + check:test-typecheck): formula 0, lint 0, mcp 0
red first, on main's validate.ts (head 81c14ba, pins only): formula 2 failed, lint 3 failed
```
</details>
<details><summary>Ablations (scripts/ablation-replace.mjs; each
restored: blob == HEAD, git diff HEAD empty, dist marker absent after
rebuild)</summary>
```
A1 formula: the sweep arm off (`scope === 'flattened' && String(timeRelativeSweep) === 'ablated-30250-a1'`),
rebuilt, marker present in 2 built files (ablation-dist-preflight exit 0)
formula × on a time-relative sweep … names no write (#30250) · × type-mismatch-sweep-run-fails (PINS) · × the SWEEP
Tests 3 failed | 179 passed (182)
lint × refuses a type mismatch on a time-relative sweep … (#29959, #30026) · × names no firing write … (#30250)
Tests 2 failed | 471 passed (473)
mcp Tests 38 passed (38): parity holds, both halves read the same ablated validator (A2 is the MCP pin)
A2 mcp: the sweep key not passed (`? { ablated30250a2: true } : {}`)
× site flow_condition, triggerKind time_relative · × … sweepObject objectName · × … start names no objectName
Tests 3 failed
A3 formula: the fail-closed date arm off (`String(faultOutcome) === 'ablated-30250-a3'`), rebuilt, marker in dist
formula (src) × on a fail-closed site says the write is refused … (#30250) · × date-arithmetic-fail-closed
· × date-text-arithmetic-fail-closed · × the SWEEP — Tests 4 failed | 178 passed (182)
lint (dist) × says a fail-closed validation rule's date arithmetic refuses the write … (#30250)
Tests 1 failed | 472 passed (473)
```
</details>
## Acceptance notes
- **An advisory (`warning` / `info`) validation rule's date arithmetic
still says "silently evaluates to null".** There a fault skips the rule
and the write proceeds (`type-mismatch-fail-open`'s sentence), so
"evaluates to null" is not true either. Out of this card's ruling
(fail-closed only), left as is and pinned as a control. Reported to the
seat as a finding in the same family.
- **`content/docs/data-modeling/formulas.mdx` date-arithmetic row** says
"this always faulted to `null`". That describes a formula's runtime, and
this PR changes no runtime, so it is left as is.
- **Serial check.** The open PRs touching
`packages/formula/src/validate.ts`,
`packages/lint/src/validate-expressions.ts` or
`packages/mcp/src/mcp-http-tools.ts` were not enumerated by this dev
(the seat's claim cleared them at 12:18Z). `origin/main` was merged once
at `fc42e4af` with no conflict, and none of the 8 incoming commits
touches these packages.
## Verification
All at head `fc42e4af` (`git rev-parse --short HEAD`), the commit this
PR opens on (`origin/main` merged once).
- Full build after the merge: `pnpm install --frozen-lockfile && turbo
run build --filter='!@objectstack/docs' --concurrency=2`, 119/119 tasks,
`VERDICT command-exit 0`.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 88 derived families, each run on this head,
all **exit 0**. `check-plugin-teardown-shape.mjs --self-test` first
answered exit 3 because the shallow clone could not read its pinned
fixture commit `621a4876`; after fetching that commit it answered exit 0
("48 cases pass"), and the record carries that run. `--ran`: "88 derived
famil(ies) accounted for — 88 run, 0 NOT-MEASURED". Among them
`check-adr-0087-registration --base origin/main`,
`check-changeset-no-major`, `check-empty-changeset`,
`check:doc-authoring`, `check:docs-transcript-drift`,
`check:test-source-alias`, `check:cross-package-test-inputs`,
`check:dts-closure`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:nul-bytes`, `lint
check:doc-formula-expressions`.
- `pnpm lint` (`eslint . --no-inline-config`): exit 0.
- Full dogfood: not run. The title has no `!` and `Clause-②` has no
`(narrowing)` arm.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Utp6CbntgiC5D1vTyg2pBV
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 38d416f commit 67d5d53
9 files changed
Lines changed: 334 additions & 40 deletions
File tree
- .changeset
- content/docs/data-modeling
- packages
- formula/src
- lint/src
- mcp/src
| 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 | + | |
| 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 | |
|---|---|---|---|
| |||
296 | 296 | | |
297 | 297 | | |
298 | 298 | | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
299 | 316 | | |
300 | 317 | | |
301 | 318 | | |
| |||
343 | 360 | | |
344 | 361 | | |
345 | 362 | | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
346 | 380 | | |
347 | 381 | | |
348 | 382 | | |
| |||
458 | 492 | | |
459 | 493 | | |
460 | 494 | | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
461 | 513 | | |
462 | 514 | | |
463 | 515 | | |
| |||
690 | 742 | | |
691 | 743 | | |
692 | 744 | | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
693 | 763 | | |
694 | 764 | | |
695 | 765 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
60 | 60 | | |
61 | 61 | | |
62 | 62 | | |
63 | | - | |
| 63 | + | |
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
| |||
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
169 | | - | |
| 169 | + | |
| 170 | + | |
170 | 171 | | |
171 | 172 | | |
172 | 173 | | |
173 | 174 | | |
174 | 175 | | |
175 | 176 | | |
176 | 177 | | |
177 | | - | |
| 178 | + | |
| 179 | + | |
178 | 180 | | |
179 | 181 | | |
180 | 182 | | |
| |||
193 | 195 | | |
194 | 196 | | |
195 | 197 | | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
196 | 207 | | |
197 | 208 | | |
198 | 209 | | |
| |||
219 | 230 | | |
220 | 231 | | |
221 | 232 | | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
222 | 240 | | |
223 | 241 | | |
224 | 242 | | |
| |||
297 | 315 | | |
298 | 316 | | |
299 | 317 | | |
| 318 | + | |
| 319 | + | |
300 | 320 | | |
301 | 321 | | |
302 | 322 | | |
303 | 323 | | |
| 324 | + | |
304 | 325 | | |
305 | 326 | | |
306 | 327 | | |
| |||
0 commit comments