Skip to content

fix(core): declare the field-rule fault directions, and stop a blank predicate defaulting in silence (objectui#8069) - #8904

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-8069-field-rule-fault-direction-declared
Sep 10, 2026
Merged

fix(core): declare the field-rule fault directions, and stop a blank predicate defaulting in silence (objectui#8069)#8904
huangyiirene merged 3 commits into
mainfrom
claude/issue-8069-field-rule-fault-direction-declared

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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 (a
loud-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.

resolveFieldRuleState handed evalFieldPredicate three bare positional booleans
(true / false / false) and answered the adjacent "no rule declared" case with the
same 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:

const VISIBLE_WHEN_FAULTED = true;    const VISIBLE_WHEN_ABSENT = true;
const READONLY_WHEN_FAULTED = false;  const READONLY_WHEN_ABSENT = false;
const REQUIRED_WHEN_FAULTED = false;  const REQUIRED_WHEN_ABSENT = false;

The dispatch stated the deliverable as a readability criterion, not a form. Quoted verbatim:

下一个读这段代码的人,能看出那三个 true/false/false 回答的是「规则坏了该怎么办」,而不是「作者没写规则该怎么办」。

2. A declared-but-blank predicate is no longer silent.

'' 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). 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.

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 this
branch'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.

declarations found in emitted head:
[["VISIBLE_WHEN_FAULTED","true"],["READONLY_WHEN_FAULTED","false"],["REQUIRED_WHEN_FAULTED","false"],
 ["VISIBLE_WHEN_ABSENT","true"],["READONLY_WHEN_ABSENT","false"],["REQUIRED_WHEN_ABSENT","false"]]
PASS: emitted JS is identical after back-substitution (2283 chars each)

Negative control — the proof must be able to fail. Flipping one constant to false on disk
(marker counts 1 → 0 / 0 → 1, blob ef55363646f33f6c) turns it red at the exact call
site:

FAIL: emitted JS differs after back-substitution
first divergence at char 1788
BASE … evalFieldPredicate(rules.visibleWhen, record, true,  previous, scope, diag('visibleWhen'))
HEAD … evalFieldPredicate(rules.visibleWhen, record, false, previous, scope, diag('visibleWhen'))

The mutation was reverted and the file proved byte-identical by blob hash
(ef553636db555ed84218bfad8646619963bd8910), not by an exit code — the first restore
attempt 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.

  • Leg A — put back the pre-fix early return that skipped the report
    (if (pred == null || (typeof pred === 'string' && !pred.trim())) return fallback;).
    Blob 6c005f696421d1ac. 6 failed | 43 passed, red by name on every
    blank-predicate case.
  • Leg B — revert only the locator half of the dedupe key. Blob 6c005f6930e2535a.
    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.
  • Baseline — unmutated: 49 passed (49).

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 four fault directions
cases stayed green under both mutations. A control that reds when the subject reds is
not a control.

The call-site table, re-weighed on b686ebf7d

git grep -n 'evalFieldPredicate(' over *.ts / *.tsx excluding tests: 22 occurrences
in 8 files
(21 calls plus the declaration). Five distinct fault policies, not four —
the dispatch's table omitted ExpressionEvaluator.evaluateCelCondition, which is the second
divergence-probe site and the only one that converts a fault into a throw:

  1. fixed permissive paired with an equal "no rule" literal — resolveFieldRuleState ×3,
    resolveVisibleOptions;
  2. fixed permissive unguarded, one literal answering absent and faulted — the form
    renderer ×7, console's FormPage ×2, app-shell's ScreenView, plugin-form's
    WizardForm, all true;
  3. caller-parameterised — evalRowPredicate's fast route (opts.fallback);
  4. divergence probe → fault flagevalCel;
  5. divergence probe → throwExpressionEvaluator.evaluateCelCondition under
    throwOnError.

Policies 4 and 5 exist because fallback is freely specifiable: they detect a fault by
disagreeing 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 log answered 1 commit for
this file — an instrument boundary, not a reading. This worktree was unshallowed
(git fetch --unshallow; is-shallow-repository false, rev-list --count HEAD 9890,
ancestry control leg exit 0). git log --follow then 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: true for visibility (don't hide content on
error), false for 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 fallback reads "Value to return when the predicate is absent or
fails 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 test135 files, 2899 tests, all passed.
  • pnpm --filter @object-ui/core type-check — passes. ⚠️ Its first run exited 2 with
    TS6305 … has not been built from source file, which is PREREQUISITE NOT MET, not red;
    re-run after pnpm --filter '@object-ui/core^...' build it surfaced a real TS18048
    (Expression['source'] is optional), fixed in d104dc8b6.
  • Consumer suites for every package that calls the helper —
    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, of
    which 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 hits
    in eslint.config.js, against a control token that hits), so this diff cannot move a verdict
    on any file it does not touch. CI's pnpm lint is turbo run lint, i.e. the per-package
    script run above.
  • node scripts/check-changeset-presence.mjs — green, 1 changeset for 3 changed source
    files of 1 released package.

Deliberately NOT in this PR

No fallback value moves · nothing is written into @objectstack/spec or ADR-0089 (Q2) ·
no loud-but-safe middle state (Q3) · objectui#6958's clearing behaviour is untouched — it
leans on the visibleWhen half staying fail-open, and any flip to fail-closed would rebuild
the silent data-loss shape it exists to prevent.

One route considered and declined, stated because declining it is a choice: tightening
ExpressionWireSchema with .min(1). That is a narrowing of an accepted set — it would
make 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 metadata
already 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 fallback conflated "absent" with "fails to evaluate" from
    the 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 entries
    that 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

Clause-② — yes

isBlankPredicateText becomes a new exported symbol on a published package
(packages/core/src/evaluator/index.ts is a export * barrel, re-exported from the package
entry). Under the mechanical floor that is yes on its own. Labelled
needs:contract-review; ⛔ do not queue or arm this PR until the review sub-round clears it.


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3487.4 KB 3512.7 KB
Main entry chunk (gzip) 144.1 KB 350 KB
Entry file index-Pl3w5nOC.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.23KB 114.67KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 26.68KB 8.94KB
data-objectstack (index.js) 200.01KB 55.77KB
fields (index.js) 246.97KB 62.30KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.54KB 46.34KB
plugin-dashboard (index.js) 131.71KB 34.50KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 252.45KB 65.33KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.26KB 34.13KB
plugin-gantt (index.js) 166.96KB 40.93KB
plugin-grid (index.js) 210.86KB 57.28KB
plugin-kanban (index.js) 57.53KB 16.46KB
plugin-list (index.js) 112.54KB 27.65KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS (audit reading; director seat, summon #18 segment 6, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00xxZ)

PR objectui#8904 · head d104dc8b606fdb728f58ad0bc5b3dd9e2579ca55 (re-read at posting 00:05:40Z; unchanged since 22:14Z) · card objectui#8069.

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (92 harness model stamps, all claude-fable-5-1, zero residue; positive control 78 assistant / 57 user role tokens), adopted verbatim below.
  • Implemented-by: session_01611D6ZaRaMmwTNQmSbk8MH · branch claude/issue-8069-field-rule-fault-direction-declared (newest Claim: 5608851498, PM-dispatched under os-zhuang). Distinct sessions ⇒ not a self-review.
  • Reading for the seat: PASS; findings F3–F6 are non-blocking successor notes for the card. Part of #8069 ⇒ card survives landing (manual pm:dispatched strip); re-hang of the carrier on the card confirmed correct. ⛔ This seat cleared no carrier and touched no PR state at posting.

Verdict: PASS — contract-review tier. Clear both carriers (needs:contract-review on PR #8904 and card #8069) in one stroke; findings below are non-blocking and go on the card as successor notes.

Head reviewed: d104dc8b606fdb728f58ad0bc5b3dd9e2579ca55 (matches the d104dc8b60 prefix; head did not move). Base f52a9d7ad, merge-base b686ebf7d, 3 commits, 4 files (+437/−31): .changeset/8069-field-rule-fault-direction-declared.md, packages/core/src/evaluator/fieldRules.ts, packages/core/src/evaluator/declaredPredicate.ts, packages/core/src/evaluator/__tests__/fieldRules.test.ts. Draft, mergeable_state: clean, first line Part of #8069 — …; no closing keyword adjacent to any issue number (regex scan of the body: 0 hits; the "fixed in d104dc8b6" strings name a sha, not a card). Card closed_by_pull_requests.total_count = 0.

Clause-② reading: yes is correct and is the only defensible value. isBlankPredicateText becomes a new exported symbol on published @object-ui/core (declaredPredicate.ts:37 export functionevaluator/index.ts:12 export * from './declaredPredicate.js'packages/core/src/index.ts:44 export * from './evaluator/index.js'; private not set, version 17.6.0). That is the mechanical floor. No published key or accepted-set change: ExpressionWireSchema (packages/types/src/zod/expression.zod.ts:43-46, bare z.string()) is untouched, so nothing that stored before stops storing. Claim match: PR body "Clause-② — yes" and card claim comment 5608851498 (dev appended Clause-②: yes under the PM's pre-authorization). PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-clause2-carriers.mjs --pair 8904exit 0 ("both carriers agree", no widening-tell row, as expected for yes).

Governed surface: node scripts/check-governed-queue-guard.mjs --test <4 paths> → NOT GOVERNED, exit 0. Ordinary review/merge-queue route.

CI on head: 33 check runs on d104dc8b60, all completed; 30 success, 3 skipped (the two coverage-shard variants and dependabot, conditional jobs). Lint, Type Check, Test (shard 1-4/4), Build & E2E, Changeset Declaration, Changeset Bump Policy, Governed Surface Queue Guard, README Export Check all green. Local test run NOT MEASURED: /home/user/objectui has no node_modules and installing would write to the shared checkout; CI on the exact head is the reading.

Findings

F1 — Security-adjacent reading: no verdict moves on any surface; the PR is exactly the ruled zero-behaviour half. Every fallback value is byte-identical: fieldRules.ts:323-325 (VISIBLE_WHEN_FAULTED = true, READONLY_WHEN_FAULTED = false, REQUIRED_WHEN_FAULTED = false) and :335-337 (*_ABSENT, same values), consumed at :390/:395, :403/:408, :423/:428. Nothing fails closed that failed open before; nothing refuses. This matches the dispatch ruling (comment 5608851498 §1 items 1-3, §2 fences: no value change, no spec/ADR-0089 write, no Q3 middle state, objectui#6958 untouched) and is consistent with today's #8164 audit stance (evaluateVisibility fails open by design — the direction is a human ruling, Q2/Q3, still open on the card). The PR body's "Deliberately NOT in this PR" section states this; the changeset states "No fallback value moves." Correct.

F2 — What actually changes, per surface (diagnostic only, verdict unchanged): evalFieldPredicate (fieldRules.ts:239 now guards only null/undefined; :247-257 routes both blank spellings to [blank] … via the single reporting site, so console.warn and onFault both fire).

  • Field rules visibleWhen/readonlyWhen/requiredWhen via resolveFieldRuleState: blank string was silent → now warns; blank envelope {source:''}/{source:' '} used to warn with a misleading engine reason ("AST-only…"/"Unexpected token: EOF") → now [blank]. Verdict = permissive fallback in all cases, as before.
  • Per-option visibleWhen (optionRules.ts:100-103, guard == null only): same as above, now loud for blank.
  • Form renderer ×7, console FormPage ×2, app-shell ScreenView, WizardForm (fallback: true): blank string now warns once per locator; verdict true unchanged.
  • evalRowPredicate fast route (listConditional.ts:332) and evalCel divergence probe (:151-152): a blank envelope reaching them already registered as a fault (both probes track the fallback); only the reason text changes from an engine parse message to [blank]. ExpressionEvaluator.evaluateCelCondition guards if (!source.trim()) return true at ExpressionEvaluator.ts:422 before the helper — untouched (stated by the dev as out-of-scope Add public roadmap, VitePress documentation site, and GitHub Pages deployment #3 and carried onto the card by the dispatch seat). No out-of-tree consumer that resolved before stops resolving; a consumer's onFault now also sees blank, which for the two in-repo probe callers is a strictly better reason string, not a new fault class (they already flagged it by divergence).

F3 (non-blocking, record on card) — one residual silent-blank path not named in the PR's out-of-scope list. evalRowPredicate listConditional.ts:253 if (source !== undefined && !source.trim()) return fallback; returns before any diagnostic for a blank string row predicate (conditional formatting, row-action visible/disabled, data-table.tsx:244, RowActionMenu.tsx:178, containers.tsx:1430, useExpression.ts:290, app-shell bridges). After this PR the same surface is loud for the envelope spelling (it passes the guard and hits the helper) but silent for the string spelling — the string/envelope asymmetry objectui#3960 removed elsewhere. Also optionLint.ts:158 if (!source.trim()) continue; skips a blank option predicate at authoring-lint time, so the metadata-diagnostics banner never flags it either. Both are outside the dispatched item 2 (which was the helper's first line) and belong with the Q3 ruling alongside ExpressionEvaluator.ts:422; they should be listed next to it on the card so "blank is now loud" is not over-read.

F4 (non-blocking) — locator-keyed dedupe is only as good as the locator. fieldRules.ts:157-159 keys a blank warning on [dialect, source, context ?? '']. resolveFieldRuleState callers pass field '${name}' (form.tsx ×5, WizardForm, FormPage, requiredWhenPrompt) — good — but GridField.tsx:480 passes no fieldContext, so every blank readonlyWhen/requiredWhen across all grid columns shares one key ("readonlyWhen"), and evalRowPredicate's fast route passes context: opts.label which may be undefined. The first blank rule still silences the rest on those two paths. Not a regression (they were fully silent before); a one-line follow-up.

F5 (non-blocking, doc accuracy) — policy-2 row of the new call-site table overstates. fieldRules.ts:188-191 says the out-of-core visibility sites "pass true, and none has a separate absent branch at all". ScreenView.tsx:98-104 is f.visibleWhen ? evalFieldPredicate(…, true, …) : true (a policy-1 ternary), form.tsx:1334-1336/1419/1486 guard with != null/continue before calling, and WizardForm.tsx:618 uses visibleOn == null ||. The literal is the same (true) so the substantive point — absent and faulted receive one value — holds, but "no absent branch" is wrong for those sites. Docblock-only; fix on the next touch of the file rather than a rework round.

F6 (non-blocking) — pins per surface. Tests pin: blank string/empty/envelope via evalFieldPredicate (fieldRules.test.ts:421-499), onFault passback (:471), locator dedupe both ways (:485, re-render), three controls (:501/:511/:520 — absent silent, healthy verdict fallback-independent, broken still Reason: [ not [blank]), and resolveFieldRuleState blank visibleWhen with field locator (:529). The three direction constants are pinned through the public surface with FAULTED/ABSENT pairs (:373-419). Not pinned: blank readonlyWhen/requiredWhen through resolveFieldRuleState, blank option visibleWhen through resolveVisibleOptions, and the blank-envelope reason through evalRowPredicate. All flow through the same helper line so coverage is transitive; worth three short cases when F3 is picked up. The removed negative baseline (' ' at :256) was split into the new describe rather than edited in place, with the reason recorded — correct handling.

F7 — Scope, changeset, docs. Changed files ⊆ card scope; no content/docs/releases/ edit; no unrelated files. Changeset @object-ui/core: minor is right (new export + new diagnostics; objectui never declares major); it names the new export, states "no fallback value moves", and names the only observable delta (a new warning/onFault reason for blank predicates) — adequate as the migration note. content/docs/guide/metadata-diagnostics.md:160-170 describes runtime field-rule faults as "safe default + one console.warn per predicate with source, reason, field"; a [blank] line fits that description, nothing there is contradicted, and no doc claims a blank predicate is silent — no docs change required. README Export Check is green; core's README does not enumerate evaluator exports.

F8 — Premise re-checked by the reviewer. ExpressionWireSchema on origin/main is a bare z.string() union (packages/types/src/zod/expression.zod.ts:43-46), used by form.zod.ts:632/634/636; positive control .min(1) exists in base.zod.ts:558/576. P1 (blank is authorable) holds. Note for Q2: the objectstack spec's own ExpressionSchema.source is z.string().min(1).optional() and EvaluatedExpressionSchema refuses blank-after-trim (packages/spec/src/shared/expression.zod.ts:92,153-160 on objectstack origin/main), so the producer-side answer the card leaves open already exists upstream in a stricter form; objectui's wire type is the looser one. The dev's declined .min(1) route was correctly declined (narrowing on a shared wire, does not close the runtime hole).

Acceptance notes

  • Implemented-by: session session_01611D6ZaRaMmwTNQmSbk8MH · branch claude/issue-8069-field-rule-fault-direction-declared (newest Claim: = card comment 5608851498, PM-posted, dev inherited; PR head.ref matches; no second claim). Reviewed-by: session_017Js5kTpTtxieBjPyScgxJ3 (director seat). Different sessions — independent review, not SELF-REVIEW.
  • Part of handling: correct — Part of #8069, no Fixes; the card stays open for Q2 (contract placement) and Q3 (loud-but-safe middle). On landing, pm:dispatched must be removed from Every field-rule predicate falls back to the PERMISSIVE verdict — one typo widens the form in all three directions at once #8069 by hand (it will not auto-close), and the dispatch seat's ACCEPT (comment 5609465532) already records the two Q3 preconditions (policies 4/5 depend on a free fallback; evaluateCelCondition:422 guard). Add F3 (listConditional.ts:253, optionLint.ts:158) and F4 to that list.
  • Re-hang of needs:contract-review on the card (dev open question C1): confirmed correct as the director seat — the label was retired on 2026-09-09T00:51Z because there was nothing reviewable; this PR introduced a new exported symbol, which is a new gate on a moved head, exactly the contract-review.md rule ("head 后移或无结论才重挂"). Not a reversal of that ruling.
  • Landing pre-checks (contract-review.md ①②③): ① this PASS is the in-seat tier verdict — record it on the card with Implemented-by:/Reviewed-by:; ② --pair 8904 exit 0; ③ all checks green on d104dc8b60. Clearing both carriers → flip ready → auto-merge/queue per landing-operations.md. Dev evidence used only git/REST (0 MCP calls) and pinned readings to the head sha; no API-instead-of-git reads to note.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Landing provenance — director seat, summon #18 segment 6 (session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00:14:19Z). Clearing needs:contract-review on both carriers (objectui#8904 + card objectui#8069) on the strength of the contract-review-tier PASS at #8904 (comment) (head d104dc8b60, unchanged). Landing pre-checks (contract-review.md ①②③): ① tier verdict on the card (pointer posted); ② check-clause2-carriers.mjs --pair 8904 exit 0; ③ 33 check-runs on head, 0 red / 0 in progress; governed-surface test exit 0 (not governed); mergeable_state: clean. Next: ready → auto-merge (SQUASH) → merge-queue entry, per landing-operations.md. Executed under the maintainer's 2026-09-09 13:4xZ order 「把当前的契约复审全部处理完」 precedent; the dispatching seat keeps ACCEPT/landing-window duties (MERGED confirmation + card close-out) if it is back before the queue finishes — otherwise this seat closes out.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 10, 2026 00:14
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 1bd79c8 Sep 10, 2026
35 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-8069-field-rule-fault-direction-declared branch September 10, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants