Repository navigation
Commit f85a83b
fix(lint,objectql)!: the object save door gives the build's formula verdict, and a formula fault is logged once per object and field (#22031)
Fixes #22019
Clause-②: no (narrowing)
## What changes
**The save door gives the build's formula verdict.** `formulas.mdx` says
"the same `validateExpression` validator backs `os build` and metadata
registration". At the object save door it did not. An object whose
formula field calls an unregistered function (`sqrt(record.amount)`)
saved with a 200, and the field read `null` on every row.
- **The cause was one registry declaration, not the door's code.**
`saveMetaItem` (publish mode) and the draft promotion already run the
runtime authoring gate. That gate runs the rules the author-time
registry declares for the written type. The build's expression rule,
`validateStackExpressions` (`packages/lint/src/authoring-rules.ts`),
declared `runtimeTypes: ['flow', 'action', 'hook']`. So
`runtimeAuthoringRulesFor('object')` never dispatched it, and its
field-formula pass, the call `os build` makes, never ran on an object
write.
- **The fix is in the producer, `@objectstack/lint`.** The entry now
declares `object` as well, and it passes the gate's write type to the
rule. On an `object` write the rule runs one pass: the field-formula
pass over `fields[].expression`. It reuses the build's own
`validateExpression('value', …)` call with the build's scope (the
object's fields and field types, `record` scope). It also keeps that
pass's warnings and the unprovisioned-anchor warning on the same key.
Every other object-borne pass the build runs stays off this door, by
name. Those are: validation-rule predicates, the field-rule slots
(`requiredWhen`, `readonlyWhen`, `conditionalRequired`, `visibleWhen`)
with their `parent` and null-guard gates, option `visibleWhen`, and the
object's own action predicates. See the out-of-scope finding below.
- **The verdict is the build's finding.** The door's 422 issue and
`runAuthoringRules('build', …)` give the same rule
(`expression-invalid`), location (`object 'fx_sqrt' · field 'score'
expression`), message and hint.
- **The published signature does not move.**
`validateStackExpressions(stack)` keeps its signature. The registry
entry reaches the passes through `runStackExpressionPasses(stack,
options)`, the one body both run, which is not on the package's entry.
- **No code change in `packages/metadata-protocol`.** The landing moved
there from the claim's file surface because the producer is the
registry, measured under H1 below. The door's code was already right: it
holds no rules by design, and `authoring-rule-wiring.test.ts` refuses a
door that names one.
**The read path no longer swallows the fault.** `applyFormulaPlan`
(`packages/objectql/src/engine.ts`) mapped a failed evaluation to `null`
with no log line. It now hands the field and the evaluator's error to a
sink. The engine binds that sink to the object, at `find`, at `findOne`
and at the write response (`hydrateWriteFormulas`).
`ObjectQL.reportFormulaFault` logs one `warn` per (object, field) per
engine instance. The line names the object, the field and the error's
kind and first line, and the full message goes in the metadata. The
value stays `null`. Stored rows are not migrated or refused: a row
stored before the gate keeps reading until its object is next saved.
Measured line, for `sqrt(record.amount)` on `fx_sqrt.score`:
```text
formula field 'score' on 'fx_sqrt' could not be evaluated, so it reads null wherever this fault holds (runtime: found no matching overload for 'sqrt(dyn)'). If the expression itself is at fault — an unknown function, a missing field — `os validate`, or a re-save of the object, refuses it with a located message; if it faults on some records' values, guard the operands it reads. Reported once per object and field per engine instance.
```
## Pins
- (a) **The door refuses `sqrt(record.amount)`** with a 422
`INVALID_METADATA` that carries the build's located finding, and nothing
lands. This is pinned through the real `saveMetaItem`, and through the
real `publishMetaItem` after a draft save, which is never gated. File:
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
block "the formula field verdict".
- (b) **`floor(record.amount)` still saves**, and the row lands.
- (c) **A row stored before the gate still reads.** The object is
registered straight into the registry and the rows are written straight
to the store. `find`, `findOne` and the write response read `null`, and
the logger gets one line naming the object and the field, however many
rows and reads. A second faulting field gets its own line, a formula
that evaluates logs nothing, and a second engine instance says it again.
File: `packages/objectql/src/engine-formula-fault-log.test.ts`.
- (d) **The door and `os build` give the same verdict text.** Rule,
where, path, message and hint are compared key by key against
`runAuthoringRules('build', …)`.
- **The lint door and its fence** are in
`packages/lint/src/runtime-gate.object-formula-writes.test.ts`. They
cover the refusal, the field-existence half, the control, door/build
parity, and the differential (a stored sibling's broken formula is not
this write's). The fence is a body with a fault in each fenced pass: the
build flags every one, and the object door flags none. The object door
roster pin in `runtime-gate.object-writes.test.ts` gains
`validateStackExpressions` as a ruled join.
- **No dogfood case.** A door-level pin is not needed. The REST mapping
of the protocol's 422 `INVALID_METADATA` on `PUT /meta/object` is
already pinned (`packages/rest/src/meta-object-owd-gate.test.ts`), and
(a) runs the protocol door itself.
## Reverse verification (one-off, from committed HEAD `fca16e0688`)
- **What was removed.** `'object'` was taken out of the entry's
`runtimeTypes` with `scripts/ablation-replace.mjs`: anchor hit 1 time, 1
→ 0, blob `5dd250340913` → `d1ae5ae5f889`.
- **Rebuild and dist proof.** `@objectstack/lint` was rebuilt.
`ablation-dist-preflight` on the pristine HEAD build found the marker
present in 4 built files. On the mutated build, `--absent
--source-marker=…` found it absent from all 14 built files.
- **The door (dist-mediated): red as predicted.** (a) ×2 and (d) went
red: 3 failed, 1 passed. (b) stayed green.
- **The lint door test (source): 6 failed, 3 passed.**
- **Restore.** The source was restored: blob `5dd250340913` equals HEAD,
and `git diff HEAD` is empty (0 bytes). Lint was rebuilt and the
preflight found the marker back in 4 built files. Both suites went green
again: door block 4 passed, lint 9 passed.
- **(c), a separate ablation.** The sink call (`onFault?.(fp.name,
r.error)`) was deleted, and the objectql pin went 5 failed / 1 passed.
The case still green is the one that pins the unchanged `null` answer.
Restored blob-equal, and `git diff HEAD` empty.
## Measurements (H1–H4)
- **H1: confirmed in part, falsified in part.** The save path judges an
object body at `assertRuntimeAuthoringRules`:
`packages/metadata-protocol/src/protocol.ts:20410` for `saveMetaItem`
and `:21973` for the draft promotion. That calls
`evaluateRuntimeAuthoringGate` (`runtime-authoring-gate.ts:897`), which
calls `runRuntimeAuthoringRules`
(`packages/lint/src/runtime-gate.ts:917`), filtered by
`runtimeAuthoringRulesFor` (`:550`). Save and publish therefore already
share one gate. The falsified half: the check does not belong in the
door. The build's entry is the registry rule `validateStackExpressions`.
Its formula call is at `packages/lint/src/validate-expressions.ts:2043`
at base (`:1957` at head), and the dispatch gap is the declaration at
`authoring-rules.ts:574` at base. The fix reuses that call and copies
nothing.
- **H2: these are the object-borne expression sites the build judges**
(`validate-expressions.ts`):
- `validations[].condition` and `.when`, with null guards over `then`
and `otherwise`;
- `fields[].requiredWhen`, `readonlyWhen`, `conditionalRequired` and
`visibleWhen`, with the root verdict, the `parent` gate, the
`requiredWhen` null guard and the traversal refusal;
- `fields[].options[].visibleWhen`;
- `fields[].expression`, with the unprovisioned-anchor warning;
- `actions[].visible` and `actions[].disabled`.
- Default values are NOT judged by the build, so the hypothesis's
"default values" is falsified. Only the formula pass is mirrored. The
rest is the out-of-scope finding below.
- **H3: confirmed.** At base, `engine.ts:2255` mapped a fault to `null`
with no log, and the planning compile at `:1562` discards its result.
Two per-key log-once shapes exist. One is the module-global `warnOnce`
(`validation/record-validator.ts:1755`, `console.warn`, per process).
The other is the engine's per-instance sets
(`transactionUnsupportedReported` `:3683` and `cascadeNotAtomicReported`
`:3692`, used by `warnCascadeNotAtomic` `:15911` through
`this.logger.warn`). Neither is a callable helper. The new report
follows the per-instance engine shape (`formulaFaultReported`,
`reportFormulaFault`), so it goes through the engine's logger.
- **H4: confirmed; the stop condition was not met.** Every stored
formula field in this repository was judged by the build's pass and by
the door's own function at its snapshot shape: 29 fields on 28 objects.
That is examples 7 on 6 (`app-crm` 4 on 3, `app-showcase` 2 on 2,
`app-todo` 1 on 1, `app-multi-package` none) and the platform
`display_title` formulas 22 on 22. The result was 0 build errors, 0
build warnings, 0 door errors and 0 door advisories. The card's `sqrt`
body, used as a positive control in the same harness, gave 1 build error
and 1 door error. No templates carry formula fields.
## Clause-② (measured)
- **Accept set: narrowing.** An object write in publish mode, including
the draft promotion and the package draft publish, answered 200 for a
formula field whose expression the validator refuses. It now answers
422.
- **Built entry declarations, base vs head**, each package rebuilt from
base sources and then restored, blob-equal:
- `@objectstack/lint`: one doc comment added
(`AuthoringRuleContext.runtimeWriteType`), plus chunk-hash renames. No
exported signature moves.
- `@objectstack/objectql`: three `private` member names on `ObjectQL`
(`formulaFaultReported`, `formulaFaultSink`, `reportFormulaFault`), plus
chunk-hash renames.
- `@objectstack/metadata-protocol`: no source change.
- **Changesets.** `.changeset/22019-object-save-door-formula-verdict.md`
covers `@objectstack/lint` and `@objectstack/metadata-protocol`:
`minor`, BREAKING, with the remedy and the ADR-0087 disposition
`not-required (no-migration-prescription)`.
`.changeset/22019-objectql-formula-fault-log.md` covers
`@objectstack/objectql` as a `patch`.
## Tests and gates (at `fca16e0688`)
- **Package tests:**
- `@objectstack/lint`: 120 files, 5638 tests passed.
- `@objectstack/metadata-protocol`: 219 files passed and 3 skipped;
28049 tests passed and 19 skipped.
- `@objectstack/objectql`: 379 files, 7513 tests passed.
- `typecheck` passed for all three packages.
- **Gates.** The `dispatch-gates.mjs --commands` list was re-derived at
this head (68 commands, unchanged from the derivation at `2850ecbec6`).
It was run together with the artifact-roster block (54) and the four
symbol-anchor sweeps: 125 commands in all, all green. Two notes on that
run:
- `check:dual-build-cjs-loads` and `check:published-readme-exports`
first answered PREREQUISITE NOT MET. They were re-run green after a full
workspace build.
- `check:engine-double-contract` and `check:objectql-double-limit` were
red on a first draft that carried its own fake engine. The pins now ride
an already-pinned double, and the objectql fake driver applies the
caller's bound after its filter.
- **PR-body gates.** `check-partof-closing-keyword` and
`check-changeset-no-major --event` were run against this body before it
was posted. `check-closing-target-claim` and `check-single-claim-paths`
need this PR's number, so their readings are in the report on the card.
## Acceptance notes
- **The planning compile in `planFormulaProjection` (`engine.ts:1562`)
does nothing.** `ExpressionEngine.compile` never throws (the CEL engine
catches and returns `{ ok: false }`), and its result is not read. Its
comment ("surface syntax errors at planning stage") and the
`evaluateFormulaField` docblock ("a formula that does not COMPILE
throws") describe a throw that never happens. This PR leaves both
untouched. No carrier.
- **`evaluateFormulaField`, the exported hook-side helper with no
engine, still answers `null` without a line.** The changeset says so. No
carrier.
- **A comment is stale.** `protocol.runtime-authoring-gate.test.ts:360`
says "ALL SEVEN object-gated rules", which was already nine before this
PR and is ten after it. No carrier.
- **Docs.** `formulas.mdx` was not edited. Its promise ("backs `os
build` and metadata registration") now holds for formula fields. The
"Build-time validation" section names `registerFlow` as the second door
and could name the object save door too: a docs addition, not a false
line.
- **QA loop.** On landing, `api-backend.formula-stdlib-matrix` A6 scores
the new behaviour. The item goes on #22017's retest list.
## Out-of-scope finding (reported for the seat to file; not filed here)
`class: b` — **the object save door still gives no build verdict on the
other object-borne expressions.** Those are: validation-rule predicates,
the field-rule slots, option `visibleWhen`, and the object's action
predicates. The contract is the same `formulas.mdx` sentence.
- **Seam:** `spec:ObjectSchema.validations[].condition /
FieldSchema.requiredWhen → runtime:runtimeAuthoringRulesFor('object')
(packages/lint/src/runtime-gate.ts)`.
- **reach:** measured through the real `saveMetaItem` in publish mode.
An object with `validations[].condition: 'sqrt(record.amount) > 1'` and
`requiredWhen: 'amount > 1'` (a bare reference) saved with success. `os
build`'s entry refused both at error: "found no matching overload for
'sqrt(dyn)'" and "bare reference `amount`".
- **Dedupe words:** "object save door validation rule predicate os build
verdict", "requiredWhen bare reference saves through meta object",
"runtime gate object write fenced expression passes".
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 099a94d commit f85a83b
9 files changed
Lines changed: 772 additions & 50 deletions
File tree
- .changeset
- packages
- lint/src
- metadata-protocol/src
- objectql/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 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
100 | | - | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| |||
306 | 306 | | |
307 | 307 | | |
308 | 308 | | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
309 | 315 | | |
310 | 316 | | |
311 | 317 | | |
| |||
570 | 576 | | |
571 | 577 | | |
572 | 578 | | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
573 | 601 | | |
574 | | - | |
575 | | - | |
576 | | - | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
577 | 605 | | |
578 | 606 | | |
579 | 607 | | |
| |||
Lines changed: 164 additions & 0 deletions
| 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 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
114 | 120 | | |
115 | 121 | | |
116 | 122 | | |
| |||
391 | 397 | | |
392 | 398 | | |
393 | 399 | | |
| 400 | + | |
394 | 401 | | |
395 | 402 | | |
396 | 403 | | |
| |||
0 commit comments