fix(core): declare the field-rule fault directions, and stop a blank predicate defaulting in silence (objectui#8069) - #8904
Conversation
…ther fallback policies (objectui#8069) `resolveFieldRuleState` handed `evalFieldPredicate` three bare positional booleans — `true` / `false` / `false` — and answered the adjacent "no rule declared" case with the SAME literal, so each permissive value was written twice and nothing in the source said which of the two questions either copy answered. Zero behaviour change, and the values are untouched. Six module-private constants now spell the two questions apart (`*_WHEN_FAULTED` vs `*_WHEN_ABSENT`) and carry, in one place, what the direction is, that the three faults compose rather than cancel, and what the history does and does not record about why they point that way (objectui#1578 and ADR-0036 record a per-key "a fault is safe" rationale; no commit puts the case where all three faults arrive from one typo). `evalFieldPredicate`'s docblock gains the call-site policy table: five distinct fault policies share this one helper, two of which — `evalCel` and `ExpressionEvaluator.evaluateCelCondition` under `throwOnError` — detect a fault by calling it twice with OPPOSITE fallbacks, and therefore depend on `fallback` staying freely specifiable. Part of objectui#8069. Q2 (does the direction belong in the authored contract) and Q3 (a loud-but-safe middle) stay undecided; no fallback value moves here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
…ent (objectui#8069)
`''` and `' '` are authorable — `ExpressionWireSchema` is a bare `z.string()`
with no `.min(1)`, and `resolveFieldRuleState`'s guard is `!= null`, so a blank
predicate passes both. `evalFieldPredicate` then returned the caller's
permissive fallback on its first line, BEFORE `warnPredicateFailure` or
`onFault` could fire.
That is a third state, not a spelling of either neighbour: the key is present
(so it is not "the author wrote no rule") and nothing evaluates (so no engine
fault is raised). The one state an author reaches by starting a rule and not
finishing it was the one state that said nothing at all — the exact silence
objectui#4051 / objectstack#5149 ruled out for every other fault.
Both spellings now report `[blank] the predicate is declared but empty —
nothing to evaluate` through the same single reporting site as every other
fault, on both channels (the built-in warning and the `onFault` passback, so
the fault-probing callers that pass `warn: false` are not silenced either).
Every verdict is unchanged, the envelope spelling included: `{ source: '' }`
used to reach the engine and come back "AST-only evaluation not yet supported;
persist `source`" and `{ source: ' ' }` "Unexpected token: EOF" — two
misleading reasons for one author mistake, both already resolving to the same
fallback this keeps.
Blankness is decided by `isBlankPredicateText` (`evaluator/declaredPredicate.ts`),
the repo's one definition of that question since objectui#3960, exported for
this second consumer rather than copied — a fourth local `trim()` here is the
drift it was consolidated to stop.
For this one fault class the once-per-predicate dedupe key joins the caller's
LOCATOR: a blank predicate has no distinguishing text, so every blank rule in
an app shares the key `""` and the first would silence every other author's.
Non-blank keys are unchanged.
Part of objectui#8069.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
…']` (objectui#8069) `tsc --noEmit` on a built workspace: `expr.source` is `string | undefined` in the spec, so calling `.trim()` on it is TS18048. The absent case is blank by the same rule as a whitespace-only one and lands in the same locator-joined key shape. Part of objectui#8069. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract review at
|
|
Landing provenance — director seat, summon #18 segment 6 ( Generated by Claude Code |
Part of #8069 — the zero-behaviour-change half. The card stays open: Q2 (does the fault
direction belong in the authored contract,
@objectstack/spec/ ADR-0089) and Q3 (aloud-but-safe middle) are still for a human to rule on, and this PR deliberately does not
pre-empt either. Q1 was already answered on the card by the dispatching seat (comments
5608155923 / 5608240551); this is the delivery that makes Q2 and Q3 answerable.
What lands
1. The three fault directions are named and documented — no value moves.
resolveFieldRuleStatehandedevalFieldPredicatethree bare positional booleans(
true/false/false) and answered the adjacent "no rule declared" case with thesame literal. So every permissive value was written twice, and the "the rule broke"
answer was chosen by aligning it with the "the rule is absent" answer beside it. Six
module-private constants now spell the two questions apart:
The dispatch stated the deliverable as a readability criterion, not a form. Quoted verbatim:
2. A declared-but-blank predicate is no longer silent.
''and' 'are authorable —ExpressionWireSchemais a barez.string()with no.min(1), andresolveFieldRuleState's guard is!= null, so a blank predicate passesboth.
evalFieldPredicatethen returned the caller's permissive fallback on its firstline, before
warnPredicateFailureoronFaultcould fire.That is a third state, not a spelling of either neighbour: the key is present (so it is not
"the author wrote no rule") and nothing evaluates (so no engine fault is raised). The one
state an author reaches by starting a rule and not finishing it was the one state that
said nothing at all — the exact silence objectui#4051 / objectstack#5149 ruled out for every
other fault.
Both spellings now report
[blank] the predicate is declared but empty — nothing to evaluatethrough the same single reporting site as every other fault, on both channels(the built-in warning and the
onFaultpassback, so the fault-probing callers that passwarn: falseare not silenced either). Blankness is decided byisBlankPredicateText(
evaluator/declaredPredicate.ts) — the repo's one definition of that question sinceobjectui#3960, exported for this second consumer rather than copied.
For this one fault class the once-per-predicate dedupe key joins the caller's locator: a
blank predicate has no distinguishing text, so every blank rule in an app shares the key
""and the first would silence every other author's. Non-blank keys are unchanged.3. The call-site policy table is in
evalFieldPredicate's docblock. Re-weighed on thisbranch's base rather than copied from the dispatch — see below.
Readings
The rename is byte-equivalent — a discriminating reading, not "the tests are green"
A pure rename cannot be validated by a green suite. The reading is a mechanical
back-substitution: transpile the base and the post-rename source with
removeComments: true,read the six declarations out of the emitted output (not out of a note), delete them,
substitute each identifier for the literal it was declared with, and diff.
Negative control — the proof must be able to fail. Flipping one constant to
falseon disk(marker counts 1 → 0 / 0 → 1, blob
ef553636→46f33f6c) turns it red at the exact callsite:
The mutation was reverted and the file proved byte-identical by blob hash
(
ef553636db555ed84218bfad8646619963bd8910), not by an exit code — the first restoreattempt silently dropped the trailing newline and only the hash comparison caught it.
Ablation of the blank fix — two legs, both restored by state
Run from the committed tree, each leg proved on disk (marker counts and blob hash)
before running, and each restore verified by blob hash and an empty
git diff HEAD.(
if (pred == null || (typeof pred === 'string' && !pred.trim())) return fallback;).Blob
6c005f69→6421d1ac. 6 failed | 43 passed, red by name on everyblank-predicate case.
6c005f69→30e2535a.4 failed | 45 passed — an earlier blank in the same module spends the single
text-keyed warning, which is exactly the collapse the locator prevents.
Controls moved independently in both legs:
the VERDICT is unchanged,control — an ABSENT predicate stays silent,control — a HEALTHY predicate …,control — a genuinely BROKEN predicate still warns with the engine reason, not [blank]and all fourfault directionscases stayed green under both mutations. A control that reds when the subject reds is
not a control.
The call-site table, re-weighed on
b686ebf7dgit grep -n 'evalFieldPredicate('over*.ts/*.tsxexcluding tests: 22 occurrencesin 8 files (21 calls plus the declaration). Five distinct fault policies, not four —
the dispatch's table omitted
ExpressionEvaluator.evaluateCelCondition, which is the seconddivergence-probe site and the only one that converts a fault into a throw:
resolveFieldRuleState×3,resolveVisibleOptions;renderer ×7, console's
FormPage×2, app-shell'sScreenView,plugin-form'sWizardForm, alltrue;evalRowPredicate's fast route (opts.fallback);evalCel;ExpressionEvaluator.evaluateCelConditionunderthrowOnError.Policies 4 and 5 exist because
fallbackis freely specifiable: they detect a fault bydisagreeing with themselves. Any Q3 proposal that fixes a direction inside the helper
removes the mechanism they stand on.
The dispatch's NOT MEASURED item — now measured
The dispatching seat's checkout was a shallow clone and
git loganswered 1 commit forthis file — an instrument boundary, not a reading. This worktree was unshallowed
(
git fetch --unshallow;is-shallow-repositoryfalse,rev-list --count HEAD9890,ancestry control leg exit 0).
git log --followthen answers 8 commits.The direction was reasoned about — once, at introduction (objectui#1578). Both the
module head landed by that commit and ADR-0036 record the same rationale, that the
fallbacks are chosen so a fault is safe:
truefor visibility (don't hide content onerror),
falsefor required/readonly (don't block submit or lock a field on error).What no commit records is the composition. Every recorded argument is per-key; the case
this card raises — all three faults arriving from one typo, composing rather than
cancelling — is never put. And the same introducing commit is where the two questions were
fused: its own
@param fallbackreads "Value to return when the predicate is absent orfails to evaluate". That doc line is now split, and the composition fact is recorded
beside the constants.
That is a reading for whoever rules Q2/Q3, not an argument for a direction.
Gates
pnpm --filter @object-ui/core test— 135 files, 2899 tests, all passed.pnpm --filter @object-ui/core type-check— passes.TS6305 … has not been built from source file, which is PREREQUISITE NOT MET, not red;re-run after
pnpm --filter '@object-ui/core^...' buildit surfaced a realTS18048(
Expression['source']is optional), fixed ind104dc8b6.pnpm exec vitest run packages/app-shell packages/components packages/plugin-form packages/react apps/console— 1183 files, 11715 passed | 2 skipped, 0 failed.
pnpm --filter @object-ui/core lint— exit 0, 0 errors, 536 pre-existing warnings, ofwhich the three files this diff edits contribute 0 (
--format json, per-file counts).Narrowing evidence: eslint's own config inspects 4676 files from the repo root and 234 in
packages/core; type-aware linting is not enabled (project/projectService: 0 hitsin
eslint.config.js, against a control token that hits), so this diff cannot move a verdicton any file it does not touch. CI's
pnpm lintisturbo run lint, i.e. the per-packagescript run above.
node scripts/check-changeset-presence.mjs— green, 1 changeset for 3 changed sourcefiles of 1 released package.
Deliberately NOT in this PR
No fallback value moves · nothing is written into
@objectstack/specor ADR-0089 (Q2) ·no loud-but-safe middle state (Q3) · objectui#6958's clearing behaviour is untouched — it
leans on the
visibleWhenhalf staying fail-open, and any flip to fail-closed would rebuildthe silent data-loss shape it exists to prevent.
One route considered and declined, stated because declining it is a choice: tightening
ExpressionWireSchemawith.min(1). That is a narrowing of an accepted set — it wouldmake metadata that stores today fail to store — on a schema shared by every expression wire
(
hidden,disabled, action gates), and it would not close the runtime hole for metadataalready stored or produced outside the schema. The runtime seam is where the silence was, so
that is where it is fixed. Whether the producer should also reject a blank belongs with Q2.
Acceptance notes (out of scope, filed nowhere — reported to the dispatching seat)
evalFieldPredicate's@param fallbackconflated "absent" with "fails to evaluate" fromthe day it landed; the same conflation is reproduced verbatim at policy-2 call sites
outside core (seven in the form renderer alone), which have no absent branch at all. Not
touched here — those are the sites Q3 would have to move.
packages/core/src/evaluator/declaredPredicate.ts's docblock enumerates the core entriesthat apply the blank rule "on the value side" and did not list
evalFieldPredicate,because it did not apply it. That list is now correct; whether the remaining entries have
drifted was not re-measured.
Enumeration
suites above),
warnPredicateFailure's existing diagnostics tests, and the Conditional-visibility predicates (visibleWhen/visibleOn) fail OPEN and silently — a broken predicate is indistinguishable from no predicate #4051 / governance: agent-instruction files are human-merge only — AGENTS.md carries the governed-surface rule #5149pins. One existing case pinned exactly the branch this card removes
(
does not warn for a healthy, absent, or blank predicate); it was split, not editedin place, with the reason recorded next to it.
path:lineliterals (ledger keys, not printed diagnostics)intersected with the files this diff edits: none. Measured on three carriers —
origin/mainitself (f52a9d7ad), this branch's HEAD, and the tree produced by a realgit merge --no-ffof this branch intoorigin/main(eccefe828, clean, zero fileoverlap) — each with a control word that hits (
check-doc-example-types.mjs'sUNGATED_EXAMPLESkeyspackages/core/src/evaluator/ExpressionEvaluator.ts:264/334/534,3 hits, a file this diff does not touch).
Clause-② — yes
isBlankPredicateTextbecomes a new exported symbol on a published package(
packages/core/src/evaluator/index.tsis aexport *barrel, re-exported from the packageentry). Under the mechanical floor that is
yeson its own. Labelledneeds:contract-review; ⛔ do not queue or arm this PR until the review sub-round clears it.Generated by Claude Code