…heir host binds
objectui#9645 narrowed a `scope="record"` `ConditionBuilder` mount to the roots
every host of a record-scoped condition binds, and named this cost on its face:
a mount whose host binds MORE loses the offers its evaluator really answers.
An action's `visible` / `disabled` is evaluated in the browser, where the
shell's expression scope publishes the identity roots and the feature flags.
The two curated action mounts and the generic schema-driven mount now declare
what their host binds. Which tier evaluates which metadata type is read from
the ruled host table rather than decided again here, through a third
derivation beside the lint scope and the subject vocabulary. `previous` is
withdrawn at those mounts in the same act, because no browser host binds it.
Server-evaluated mounts and every mount whose tier that table does not measure
are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Fixes #9856
The declared cost of objectui#9645, paid where it was measured. That change made a
scope="record"mount advertiseRECORD_CONDITION_ROOTS— the set EVERY host of arecord-scoped condition binds — and said on its own face that a mount whose host binds
more would lose offers until it declared them. An action's
visible/disabledisevaluated in the browser, where the shell's
buildExpressionScopepublishes theidentity roots and the feature flags, so the two action mounts had been advertising two
roots while their evaluator answered six.
取数时刻 for every reading below: 2026-09-19T15:06Z – 15:30Z, worktree off
origin/main5e5d6b245.The mount enumeration — every non-test
ConditionBuildermount, its tier, its evidenceSeven mounts, found by the JSX tag across
packages/(six files; the action inspectormounts two).
ActionDefaultInspector— "Visible when"CONDITION_HOST_BY_METADATA_TYPErulesaction: 'client'; the browser path isuseConditionoverusePredicateRecordContextplus the ambientbuildExpressionScopebagCLIENT_CONDITION_ROOTSActionDefaultInspector— "Disabled when"CLIENT_CONDITION_ROOTSwidgets.tsx— the schema-drivenConditionWidgetCONDITION_HOST_BY_METADATA_TYPErules for the type on screenWidgetContext.conditionRoots, derived byconditionRootsForMetadataTypeHookDefaultInspector— "Run only when (optional CEL)"hook: 'server';wrapDeclarativeHookevaluates against two bindings and throws otherwiseObjectValidationsPanel— a rule's guardvalidation: 'server'; the rule validator evaluates against two bindings, fail-CLOSEDPageBlockInspector— a block'svisibleWhenpagerow, and the mount deliberately passes noscopeat all — so objectui#9645 never narrowed it and it still inherits the engine's own advertisementFlowNodeConfigField— a node's entry conditionflowrow in the host table; the mount passes noscope, so it too was never narrowedThe 回翻条款 bites on the last two and it cost nothing to obey: neither was narrowed in
the first place, because
offeredRootsonly falls back to the record list when a mountdeclares
scope="record". Both are byte-for-byte unchanged.How
rootsandsubjects.contextrelate — and why they are not one propThey read the SAME ruled table and take OPPOSITE arms of it, which is the finding this
PR reports rather than acts on unilaterally.
subjects.contextis the row builder's only subject control. Its default carriesuser.*, so the tier that needs a declaration is the server one —conditionSubjectsForMetadataTypenarrowsserverand answersundefinedelsewhere.rootsfeeds the raw editor's autocomplete. Its default atscope="record"is alreadynarrowed, so the tier that needs a declaration is the client one —
conditionRootsForMetadataTypewidensclientand answersundefinedelsewhere.Recommendation: do NOT unify them, and the asymmetry is why. Dropping an entry from
the subject list removes the author's ability to BUILD that row at all; dropping one from
rootsremoves a suggestion and never a spelling, because the accept set stays theengine's own. The two controls therefore fail in different directions and deserve
different defaults. What could reasonably be unified is the derivation site, not the
props: the three
conditionXForMetadataTypefunctions now sit side by side reading onetable, which is where a future consolidation belongs. That is a judgement for the seat,
not an edit made here.
The one root this takes AWAY, deliberately
previousis no longer offered at the client mounts. No browser host binds it: the rowarrives alone through
usePredicateRecordContextand the ambient scope publishes noprevious. Offering it there is the objectui#8155 shape — an editor advertising a rootnothing answers — so the predicate an author built from it could only fault. Server
mounts keep it. This half is pinned at the mount, not only in a docblock, because it is
the half a reader is most likely to take for an oversight.
Control reading — the new pins fail against today's behaviour and pass against this
Ablation on the committed tree: the declarations were deleted from the two action mounts
and from the widget's forwarding site (on-disk proof both ways — anchor occurrences
2 / 1before,0 / 0after; restored bygit checkout HEAD --, withgit diff HEADempty and both blob hashes equal to HEAD's).
The nine that stay green under ablation are the derivation and list-membership cases:
they judge the table and the list, which the ablation does not touch. The four that flip
are exactly the ones that read the real suggestion menu at a real mount.
Validation
pnpm exec vitest runon the two new files — 13 passed.pnpm exec vitest runover the affected sibling suites (the threeConditionBuilderroots/subject pins,
mountScope, the base and celGate suites, bothConditionWidgetpins,
ResourceEditPage.conditionScope,ActionDesigner.paramVocabulary,ActionDefaultInspector.celGate) — 13 files, 134 passed.pnpm --filter @object-ui/app-shell type-check— exit 0, after building the dependencyclosure (
pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build,exit 0 under the shared verify lock).
pnpm exec eslinton the seven changed files — exit 0 (0 errors; the warnings arepre-existing
no-explicit-any/react-refreshfindings inwidgets.tsx).check:new-line-citations,check:control-bytes,check:test-path-roots,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:changeset-claims,check:pending-changeset-literals,check:unreferenced-sources,check:self-import,check:esm-specifiers— all exit 0.node scripts/check-changeset-presence.mjs— exit 0, one changeset declared.check:readme-exportsis NOT MEASURED locally: it exits 1 on an unbuilt tree with"its type entry is not on disk — run
pnpm buildfirst" for 86 self-imports acrosspackages this diff does not touch. No README changed here.
pnpm lintand the full suite are CI's runs, not this branch's.Acceptance notes
Noted, not filed — neither meets the three filing classes, and both are already carried
by an open card or by a docblock in the file a successor will open:
user / ctx". Before this change that sentence disagreed with the dropdown; it now
agrees, so there is nothing left to file. Carrier: this PR.
PageBlockInspectoris the one mount whose tier is genuinely open, and it is openUPSTREAM rather than here — the engine has no
pagescope root, which that mount's owndocblock records along with the objectstack-side card it waits on. Successor: none
needed in this repo; the mount declares nothing and is unchanged.
Session reference, in prose because a footer does not reliably survive an edit:
https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq🤖 Generated with Claude Code
https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Generated by Claude Code