Skip to content

Honour per-option visibleWhen in the metadata-admin renderer - #6618

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin
Aug 27, 2026
Merged

Honour per-option visibleWhen in the metadata-admin renderer#6618
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin

Conversation

@os-sales

@os-salesos-sales commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6247

Authored by Claude Code, session session_01CRJge11jso9TpXRWFt1Z49 (https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49) — recorded here in prose because a body edit rewrites the footer below to its bare form.

SelectOptionSchema declares a per-option visibleWhen (ADR-0068 / #2284) and the schema is z.core.$strict — an undeclared sibling key is refused with unrecognized_keys — so the key is a real declaration and a *.form.ts carrying a per-option predicate parses clean. The metadata-admin renderer never read it: all three controls that consume fieldSpec.options mapped the authored list straight to items. Accepted, stored, shipped, ignored — ADR-0049's declared-but-unenforced shape, failing in the permissive direction (the option stayed offered).

This implements the maintainer ruling A2 + B1 + C1, affirmed three times on the card (2026-08-25T06:24Z batch 4; 2026-08-25T10:32Z upholding A2 against the A1 counter-proposal; 2026-08-27 decision-inbox batch 2 declining the A0 alternative). No fork is re-litigated here.

What changed

FORK A → A2 + A1's diagnostic.SchemaForm's evaluatePredicate ctx now binds the four ADR-0068 D1 identity spellings — current_user, user, ctx.user, os.useralongsidedata, selected out of the host ExpressionProvider's bag rather than copied, so the alias set cannot drift from buildExpressionScope. data stays the draft: the provider's bag also carries a data key meaning its own data scope, and adopting that is the #5926 gap-2 collision the ruling's own text excludes. record, app and features stay unbound so they keep raising the loud warn-once diagnostic instead of resolving to a silent undefinedpredicate.test.ts already pins record.status as a warning, and that pin still passes. No third evaluator: the option filter routes through the same evaluatePredicate the section, field and repeater-row gates use. All five existing call sites in the file now build their ctx through the one buildPredicateCtx, so current_user cannot mean one thing in an option gate and another in the field gate beside it.

The diagnostic itself had to change with the binding: it asserted "the only name is data", which stopped being true the moment identity was bound. It now reads the bound names off the actual scope. A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is in fact correct.

FORK B → B1. Face decisions keep reading the raw option list — resolveFieldFace's hasOptions, resolveColorWidgetKey, and each options.length > 0 branch condition — and only the rendered list is filtered. An emptied set renders an empty picker. Measured consequences of getting this wrong, all three now pinned: the builtin Select would have fallen through to string → Input (a free-text box for "withdraw every option"), MultiSelectWidget would have degraded to its comma-tag editor, and a fully-withdrawn palette would have flipped resolveColorWidgetKey from color-picker to color-input — making the labelling channel #4871 point 4 deliberately fixes in the host predicate-dependent.

FORK C → C1. No pruning. This includes the quiet path: MultiSelectWidget's toggle re-orders the selection against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click — pruning authored metadata through the back door, with no author action that says "remove this".

Rider — FormFieldSpec.options is no longer hand-written. It derives from the spec's SelectOption with its narrowings named in an Omit, per this file's own convention: visibleWhen re-pointed to the local VisibilityPredicate, and defaultdropped and now saying so (nothing on this surface reads it; #6263 owns that key). Two of the spec's five option keys had been dropped by silence — which is exactly how a legally-authored per-option visibleWhen came to parse clean and render inert.

Scope note — one file beyond the dispatched fence

The dispatch fenced SchemaForm.tsx / widgets.tsx / form-spec.ts + tests. This PR also edits predicate.ts (same directory). It is mechanically required by the ruling, which names "the loud warn-once dev diagnostic (predicate.ts machinery)": the ctx type was data-only and had to widen to carry the identity roots, and the diagnostic's hardcoded "the only name is data" had to stop being false. The alternative was a type lie plus a diagnostic that misleads — worse on contract-first grounds. Flagged rather than done silently. (Reviewed and accepted by the dispatching seat: the ruling names that machinery, so the dispatch fence was drawn too narrow.)

Verification

Union re-run at the final commit 06b7d6a3, after the last commit landed — the whole @object-ui/app-shell package suite plus apps/console's type-parity suite: 563 files / 5433 passed, 1 skipped, exit 0.pnpm --filter @object-ui/app-shell type-check exit 0 / 0 errors. lint exit 0 with 0 errors across 1003 linted files (counted from --format json); the single warning attributed to predicate.ts is a pre-existing any annotation present on origin/main too.

  • Ablation, run twice — once per commit, the second against the exact tree that ships. Reverting onlySchemaForm.tsx + widgets.tsx to origin/main (keeping predicate.ts, so the pins fail on behaviour rather than on a missing import) turns 8 of 15 red: every starred assertion, one per consuming control plus the fork pins. The 7 that stay green are the positive controls and the pure buildPredicateCtx block — which is the designed split. Mutation proven on disk on both legs (injected text absent, removed text back); restore proven byte-identical to the HEAD blob by git hash-object, plus an empty git diff HEAD --stat.
  • Every pin asserts an option ABSENT on a false predicate. This evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted; a positive-only pin passes against the unfixed renderer. The shown-when-true cases are kept only as controls.
  • Downstream sweep (--filter '...@object-ui/app-shell', the prefix form = dependents): all 4 consumers green. This mattered — apps/console's FormPage.viewSpec.test.ts asserts an exact type equality between its own form-view type and app-shell's, transitively covering the widened options. Predicted this could break; measured that it does not, because the console derives that type rather than restating it. A first run showed 16 errors that were entirely unbuilt siblings (TS2307); building the console's closure took it to 0.
  • tsconfig.test.json really does compile the new suite — proven, not assumed: it rejected the first fixture (a form type outside FormView's enum, and options typed loosely enough to prove nothing about the widened authoring type). Fixed in the second commit.
  • check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers, check:designer-field-key-parity, changeset:check (no major), check-changeset-presence, check-changeset-overwrite, check:control-bytes, lint:coverage, type-check:coverage — all exit 0.

Deliberately not fixed here — and it bounds the claim

This interim evaluator's in requires an array literal on the right, so ADR-0068's own headline spelling 'admin' in current_user.positions — membership against a path — falls through to the bare-truthy branch and evaluates TRUE regardless of the user, silently.

⚠️ To be exact about the mechanism, because it was briefly misread in review: the text begins with a quote, so it never reaches the in branch at all and the root is never resolved. parseLiteral returns the whole expression verbatim as a truthy string. Binding current_user therefore cannot change the outcome in either direction, and the PATH_SHAPED_LITERAL diagnostic never fired for it either (that one only matches text starting with an identifier character). Measured both ways and root-independent: 'x' in data.tags is equally inert, while the literal-set control data.kind in ['a','b'] discriminates correctly. So this PR neither creates nor worsens it — the behaviour is silently TRUE before and after.

Filed as #6617, which owns closing it; that number is recorded here for reference and is not addressed by this PR. An implementer's feasibility note on its "diagnose only" direction is recorded on that card.

The spellings this change makes genuinely discriminate are the documented subset: path == literal, path != literal, path in [literals], !path, path, &&, ||.

SelectOptionSchema declares a per-option `visibleWhen` (ADR-0068) and is
z.core.$strict, so a *.form.ts carrying one parses clean — but all three
metadata-admin controls that consume `fieldSpec.options` mapped the authored
list straight to items and never read the key. Accepted, stored, shipped,
ignored: ADR-0049's declared-but-unenforced shape, failing in the permissive
direction (the option stayed offered).
Per the maintainer ruling A2 + B1 + C1:
- A2: bind the four ADR-0068 D1 identity spellings alongside `data`, selected
out of the host ExpressionProvider's bag rather than copied. `data` stays the
draft — the provider's conflicting `data` key is NOT adopted. `record`/`app`/
`features` stay unbound so they keep raising the loud diagnostic, which now
reads the bound names off the actual scope instead of claiming "the only name
is `data`". No third evaluator: the filter routes through evaluatePredicate.
- B1: face decisions keep reading the RAW option list; only the rendered list is
filtered, so an emptied set is an empty picker — never the free-text
degradation, never a different widget registration.
- C1: no pruning; MultiSelectWidget's toggle re-orders against the raw list so a
hidden-but-selected value survives.
FormFieldSpec.options now derives from the spec's SelectOption with its
narrowings named in an Omit, instead of hand-mirroring three of five keys.
A pin per consuming control, each asserting an option ABSENT on a false
predicate — the positive case cannot distinguish fail-open from a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
`tsconfig.test.json` compiles this suite, and it caught the fixture typing its
options as `Record<string, unknown>[]` and its `type` as a `'default'` that is
not in FormView's enum. A loose fixture would have let the suite pass while
proving nothing about the AUTHORING type this card widened — the annotation is
itself the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.6 KB3266.6 KB
Main entry chunk (gzip)157.1 KB350 KB
Entry fileindex-Dsv8zCUd.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.90KB114.81KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.98KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)9.30KB3.22KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49), at head 06b7d6a3. Landing gated only on the farm: 29/29 registered, 0 failures, three test shards outstanding. ⛔ Not flipped yet — entry qualification is every check green on the reviewed head.

Why the verification convinces

⭐⭐ The load-bearing insight is the fail-direction one: this evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted. Every pin therefore asserts an option ABSENT on a false predicate, with shown-when-true kept only as controls. A positive-only suite would pass against the unfixed renderer — which is exactly how a legally-authored per-option visibleWhen shipped accepted, stored and inert.

The ablation was designed, not just run: reverting onlySchemaForm.tsx + widgets.tsx while keepingpredicate.ts makes the pins fail on behaviour rather than collapsing on a missing import — 8 red / 7 green, and the 7 green being precisely the positive controls and the pure buildPredicateCtx block is the designed split, not a shortfall. Run twice, once against the exact shipping tree, with mutation proven on disk by grepping injected and removed text separately, and restore proven byte-identical by git hash-object against the HEAD blob.

tsconfig.test.json coverage was proven rather than assumed — it rejected the first fixture with four TS2322 errors naming that file. A suite that isn't compiled is NOT MEASURED, not green, and this one demonstrated it was measured by going red first.

Two red-looking signatures correctly classified as not-red

  • 16 downstream errors on the first console run: 10 TS2307 + 5 TS2882 + 1 TS7006, all unbuilt siblings, none naming a touched file or type. Building the closure took it to 0.
  • check:spec-floors exits 1 with five [no-artifact] findings — it judges built dist/, and only the dependency closure had been built. Proven rather than argued: building app-shell itself dropped it off the list (5 → 4, zero mentions). The remaining four are untouched, unbuilt packages. ⭐ That is NOT MEASURED, not a red gate, and the proof is the right shape — a positive control that moves the count.

Also right: three scripts first returning EXIT=254 were recognised as pnpm "no such script" rather than failures, and re-run under their real names.

The B1 and C1 halves are where the value is

B1 — face decisions keep reading the raw list, so an emptied set renders an empty picker. The measured consequences of getting this wrong are the reason it matters: the builtin Select would have fallen through to string → Input, handing the author a free-text box for "withdraw every option"; MultiSelectWidget would have degraded to a comma-tag editor; a fully-withdrawn palette would have flipped resolveColorWidgetKey to color-input, making the labelling channel #4871 deliberately fixed predicate-dependent. All three now pinned.

C1 — no pruning, ⭐ including the quiet path: MultiSelectWidget's toggle re-orders against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click. That is pruning authored metadata through a back door with no author action that says "remove this" — catching it is the difference between implementing the ruling and implementing its headline.

Rider accepted: deriving FormFieldSpec.options from the spec's SelectOption with narrowings named in an Omit. Two of five keys had been dropped by silence, which is precisely how this defect existed. Naming the narrowing is what stops it recurring; default staying dropped and saying so is the right shape, with #6263 owning that key.

Fence

predicate.ts is outside the three named files in my dispatch order, and the dev declared it rather than doing it quietly. Accepted — my fence was drawn too narrow, not this change too wide. The ruling names the predicate.ts machinery explicitly; the ctx type had to widen, and the diagnostic hardcoded "the only name is data", which the binding made false. ⭐ A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is correct. Nothing else in flight touches that directory.

Retraction, restated here so it cannot be re-derived a third time

I read the in-against-path gap as a regression this PR creates, on the theory that binding current_user removes a previously-loud unbound-root warning. That was wrong and is withdrawn. The expression begins with a quote, so it never matches the in branch, falls to the bare-truthy tail, and parseLiteral returns it verbatim as a truthy string — the root is never resolved at all, so binding cannot move it, and PATH_SHAPED_LITERAL never fired for it either (it matches only text starting with an identifier character). Silent-TRUE before, silent-TRUE after, measured both ways and root-independent. #6617 owns it, correctly filed unlabelled for triage, with the implementer's feasibility note recorded there.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 27, 2026 14:47
@os-sales
os-sales added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 0ea559eAug 27, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6247-option-visiblewhen-metadata-admin branch August 27, 2026 15:00
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Honour per-option `visibleWhen` in the metadata-admin renderer by os-sales · Pull Request #6618 · objectstack-ai/objectui · GitHub
Skip to content

Honour per-option visibleWhen in the metadata-admin renderer - #6618

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin
Aug 27, 2026
Merged

Honour per-option visibleWhen in the metadata-admin renderer#6618
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin

Conversation

@os-sales

@os-salesos-sales commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6247

Authored by Claude Code, session session_01CRJge11jso9TpXRWFt1Z49 (https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49) — recorded here in prose because a body edit rewrites the footer below to its bare form.

SelectOptionSchema declares a per-option visibleWhen (ADR-0068 / #2284) and the schema is z.core.$strict — an undeclared sibling key is refused with unrecognized_keys — so the key is a real declaration and a *.form.ts carrying a per-option predicate parses clean. The metadata-admin renderer never read it: all three controls that consume fieldSpec.options mapped the authored list straight to items. Accepted, stored, shipped, ignored — ADR-0049's declared-but-unenforced shape, failing in the permissive direction (the option stayed offered).

This implements the maintainer ruling A2 + B1 + C1, affirmed three times on the card (2026-08-25T06:24Z batch 4; 2026-08-25T10:32Z upholding A2 against the A1 counter-proposal; 2026-08-27 decision-inbox batch 2 declining the A0 alternative). No fork is re-litigated here.

What changed

FORK A → A2 + A1's diagnostic.SchemaForm's evaluatePredicate ctx now binds the four ADR-0068 D1 identity spellings — current_user, user, ctx.user, os.useralongsidedata, selected out of the host ExpressionProvider's bag rather than copied, so the alias set cannot drift from buildExpressionScope. data stays the draft: the provider's bag also carries a data key meaning its own data scope, and adopting that is the #5926 gap-2 collision the ruling's own text excludes. record, app and features stay unbound so they keep raising the loud warn-once diagnostic instead of resolving to a silent undefinedpredicate.test.ts already pins record.status as a warning, and that pin still passes. No third evaluator: the option filter routes through the same evaluatePredicate the section, field and repeater-row gates use. All five existing call sites in the file now build their ctx through the one buildPredicateCtx, so current_user cannot mean one thing in an option gate and another in the field gate beside it.

The diagnostic itself had to change with the binding: it asserted "the only name is data", which stopped being true the moment identity was bound. It now reads the bound names off the actual scope. A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is in fact correct.

FORK B → B1. Face decisions keep reading the raw option list — resolveFieldFace's hasOptions, resolveColorWidgetKey, and each options.length > 0 branch condition — and only the rendered list is filtered. An emptied set renders an empty picker. Measured consequences of getting this wrong, all three now pinned: the builtin Select would have fallen through to string → Input (a free-text box for "withdraw every option"), MultiSelectWidget would have degraded to its comma-tag editor, and a fully-withdrawn palette would have flipped resolveColorWidgetKey from color-picker to color-input — making the labelling channel #4871 point 4 deliberately fixes in the host predicate-dependent.

FORK C → C1. No pruning. This includes the quiet path: MultiSelectWidget's toggle re-orders the selection against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click — pruning authored metadata through the back door, with no author action that says "remove this".

Rider — FormFieldSpec.options is no longer hand-written. It derives from the spec's SelectOption with its narrowings named in an Omit, per this file's own convention: visibleWhen re-pointed to the local VisibilityPredicate, and defaultdropped and now saying so (nothing on this surface reads it; #6263 owns that key). Two of the spec's five option keys had been dropped by silence — which is exactly how a legally-authored per-option visibleWhen came to parse clean and render inert.

Scope note — one file beyond the dispatched fence

The dispatch fenced SchemaForm.tsx / widgets.tsx / form-spec.ts + tests. This PR also edits predicate.ts (same directory). It is mechanically required by the ruling, which names "the loud warn-once dev diagnostic (predicate.ts machinery)": the ctx type was data-only and had to widen to carry the identity roots, and the diagnostic's hardcoded "the only name is data" had to stop being false. The alternative was a type lie plus a diagnostic that misleads — worse on contract-first grounds. Flagged rather than done silently. (Reviewed and accepted by the dispatching seat: the ruling names that machinery, so the dispatch fence was drawn too narrow.)

Verification

Union re-run at the final commit 06b7d6a3, after the last commit landed — the whole @object-ui/app-shell package suite plus apps/console's type-parity suite: 563 files / 5433 passed, 1 skipped, exit 0.pnpm --filter @object-ui/app-shell type-check exit 0 / 0 errors. lint exit 0 with 0 errors across 1003 linted files (counted from --format json); the single warning attributed to predicate.ts is a pre-existing any annotation present on origin/main too.

  • Ablation, run twice — once per commit, the second against the exact tree that ships. Reverting onlySchemaForm.tsx + widgets.tsx to origin/main (keeping predicate.ts, so the pins fail on behaviour rather than on a missing import) turns 8 of 15 red: every starred assertion, one per consuming control plus the fork pins. The 7 that stay green are the positive controls and the pure buildPredicateCtx block — which is the designed split. Mutation proven on disk on both legs (injected text absent, removed text back); restore proven byte-identical to the HEAD blob by git hash-object, plus an empty git diff HEAD --stat.
  • Every pin asserts an option ABSENT on a false predicate. This evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted; a positive-only pin passes against the unfixed renderer. The shown-when-true cases are kept only as controls.
  • Downstream sweep (--filter '...@object-ui/app-shell', the prefix form = dependents): all 4 consumers green. This mattered — apps/console's FormPage.viewSpec.test.ts asserts an exact type equality between its own form-view type and app-shell's, transitively covering the widened options. Predicted this could break; measured that it does not, because the console derives that type rather than restating it. A first run showed 16 errors that were entirely unbuilt siblings (TS2307); building the console's closure took it to 0.
  • tsconfig.test.json really does compile the new suite — proven, not assumed: it rejected the first fixture (a form type outside FormView's enum, and options typed loosely enough to prove nothing about the widened authoring type). Fixed in the second commit.
  • check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers, check:designer-field-key-parity, changeset:check (no major), check-changeset-presence, check-changeset-overwrite, check:control-bytes, lint:coverage, type-check:coverage — all exit 0.

Deliberately not fixed here — and it bounds the claim

This interim evaluator's in requires an array literal on the right, so ADR-0068's own headline spelling 'admin' in current_user.positions — membership against a path — falls through to the bare-truthy branch and evaluates TRUE regardless of the user, silently.

⚠️ To be exact about the mechanism, because it was briefly misread in review: the text begins with a quote, so it never reaches the in branch at all and the root is never resolved. parseLiteral returns the whole expression verbatim as a truthy string. Binding current_user therefore cannot change the outcome in either direction, and the PATH_SHAPED_LITERAL diagnostic never fired for it either (that one only matches text starting with an identifier character). Measured both ways and root-independent: 'x' in data.tags is equally inert, while the literal-set control data.kind in ['a','b'] discriminates correctly. So this PR neither creates nor worsens it — the behaviour is silently TRUE before and after.

Filed as #6617, which owns closing it; that number is recorded here for reference and is not addressed by this PR. An implementer's feasibility note on its "diagnose only" direction is recorded on that card.

The spellings this change makes genuinely discriminate are the documented subset: path == literal, path != literal, path in [literals], !path, path, &&, ||.

SelectOptionSchema declares a per-option `visibleWhen` (ADR-0068) and is
z.core.$strict, so a *.form.ts carrying one parses clean — but all three
metadata-admin controls that consume `fieldSpec.options` mapped the authored
list straight to items and never read the key. Accepted, stored, shipped,
ignored: ADR-0049's declared-but-unenforced shape, failing in the permissive
direction (the option stayed offered).
Per the maintainer ruling A2 + B1 + C1:
- A2: bind the four ADR-0068 D1 identity spellings alongside `data`, selected
out of the host ExpressionProvider's bag rather than copied. `data` stays the
draft — the provider's conflicting `data` key is NOT adopted. `record`/`app`/
`features` stay unbound so they keep raising the loud diagnostic, which now
reads the bound names off the actual scope instead of claiming "the only name
is `data`". No third evaluator: the filter routes through evaluatePredicate.
- B1: face decisions keep reading the RAW option list; only the rendered list is
filtered, so an emptied set is an empty picker — never the free-text
degradation, never a different widget registration.
- C1: no pruning; MultiSelectWidget's toggle re-orders against the raw list so a
hidden-but-selected value survives.
FormFieldSpec.options now derives from the spec's SelectOption with its
narrowings named in an Omit, instead of hand-mirroring three of five keys.
A pin per consuming control, each asserting an option ABSENT on a false
predicate — the positive case cannot distinguish fail-open from a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
`tsconfig.test.json` compiles this suite, and it caught the fixture typing its
options as `Record<string, unknown>[]` and its `type` as a `'default'` that is
not in FormView's enum. A loose fixture would have let the suite pass while
proving nothing about the AUTHORING type this card widened — the annotation is
itself the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.6 KB3266.6 KB
Main entry chunk (gzip)157.1 KB350 KB
Entry fileindex-Dsv8zCUd.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.90KB114.81KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.98KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)9.30KB3.22KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49), at head 06b7d6a3. Landing gated only on the farm: 29/29 registered, 0 failures, three test shards outstanding. ⛔ Not flipped yet — entry qualification is every check green on the reviewed head.

Why the verification convinces

⭐⭐ The load-bearing insight is the fail-direction one: this evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted. Every pin therefore asserts an option ABSENT on a false predicate, with shown-when-true kept only as controls. A positive-only suite would pass against the unfixed renderer — which is exactly how a legally-authored per-option visibleWhen shipped accepted, stored and inert.

The ablation was designed, not just run: reverting onlySchemaForm.tsx + widgets.tsx while keepingpredicate.ts makes the pins fail on behaviour rather than collapsing on a missing import — 8 red / 7 green, and the 7 green being precisely the positive controls and the pure buildPredicateCtx block is the designed split, not a shortfall. Run twice, once against the exact shipping tree, with mutation proven on disk by grepping injected and removed text separately, and restore proven byte-identical by git hash-object against the HEAD blob.

tsconfig.test.json coverage was proven rather than assumed — it rejected the first fixture with four TS2322 errors naming that file. A suite that isn't compiled is NOT MEASURED, not green, and this one demonstrated it was measured by going red first.

Two red-looking signatures correctly classified as not-red

  • 16 downstream errors on the first console run: 10 TS2307 + 5 TS2882 + 1 TS7006, all unbuilt siblings, none naming a touched file or type. Building the closure took it to 0.
  • check:spec-floors exits 1 with five [no-artifact] findings — it judges built dist/, and only the dependency closure had been built. Proven rather than argued: building app-shell itself dropped it off the list (5 → 4, zero mentions). The remaining four are untouched, unbuilt packages. ⭐ That is NOT MEASURED, not a red gate, and the proof is the right shape — a positive control that moves the count.

Also right: three scripts first returning EXIT=254 were recognised as pnpm "no such script" rather than failures, and re-run under their real names.

The B1 and C1 halves are where the value is

B1 — face decisions keep reading the raw list, so an emptied set renders an empty picker. The measured consequences of getting this wrong are the reason it matters: the builtin Select would have fallen through to string → Input, handing the author a free-text box for "withdraw every option"; MultiSelectWidget would have degraded to a comma-tag editor; a fully-withdrawn palette would have flipped resolveColorWidgetKey to color-input, making the labelling channel #4871 deliberately fixed predicate-dependent. All three now pinned.

C1 — no pruning, ⭐ including the quiet path: MultiSelectWidget's toggle re-orders against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click. That is pruning authored metadata through a back door with no author action that says "remove this" — catching it is the difference between implementing the ruling and implementing its headline.

Rider accepted: deriving FormFieldSpec.options from the spec's SelectOption with narrowings named in an Omit. Two of five keys had been dropped by silence, which is precisely how this defect existed. Naming the narrowing is what stops it recurring; default staying dropped and saying so is the right shape, with #6263 owning that key.

Fence

predicate.ts is outside the three named files in my dispatch order, and the dev declared it rather than doing it quietly. Accepted — my fence was drawn too narrow, not this change too wide. The ruling names the predicate.ts machinery explicitly; the ctx type had to widen, and the diagnostic hardcoded "the only name is data", which the binding made false. ⭐ A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is correct. Nothing else in flight touches that directory.

Retraction, restated here so it cannot be re-derived a third time

I read the in-against-path gap as a regression this PR creates, on the theory that binding current_user removes a previously-loud unbound-root warning. That was wrong and is withdrawn. The expression begins with a quote, so it never matches the in branch, falls to the bare-truthy tail, and parseLiteral returns it verbatim as a truthy string — the root is never resolved at all, so binding cannot move it, and PATH_SHAPED_LITERAL never fired for it either (it matches only text starting with an identifier character). Silent-TRUE before, silent-TRUE after, measured both ways and root-independent. #6617 owns it, correctly filed unlabelled for triage, with the implementer's feasibility note recorded there.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 27, 2026 14:47
@os-sales
os-sales added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 0ea559eAug 27, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6247-option-visiblewhen-metadata-admin branch August 27, 2026 15:00
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Honour per-option `visibleWhen` in the metadata-admin renderer by os-sales · Pull Request #6618 · objectstack-ai/objectui · GitHub
Skip to content

Honour per-option visibleWhen in the metadata-admin renderer - #6618

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin
Aug 27, 2026
Merged

Honour per-option visibleWhen in the metadata-admin renderer#6618
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin

Conversation

@os-sales

@os-salesos-sales commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6247

Authored by Claude Code, session session_01CRJge11jso9TpXRWFt1Z49 (https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49) — recorded here in prose because a body edit rewrites the footer below to its bare form.

SelectOptionSchema declares a per-option visibleWhen (ADR-0068 / #2284) and the schema is z.core.$strict — an undeclared sibling key is refused with unrecognized_keys — so the key is a real declaration and a *.form.ts carrying a per-option predicate parses clean. The metadata-admin renderer never read it: all three controls that consume fieldSpec.options mapped the authored list straight to items. Accepted, stored, shipped, ignored — ADR-0049's declared-but-unenforced shape, failing in the permissive direction (the option stayed offered).

This implements the maintainer ruling A2 + B1 + C1, affirmed three times on the card (2026-08-25T06:24Z batch 4; 2026-08-25T10:32Z upholding A2 against the A1 counter-proposal; 2026-08-27 decision-inbox batch 2 declining the A0 alternative). No fork is re-litigated here.

What changed

FORK A → A2 + A1's diagnostic.SchemaForm's evaluatePredicate ctx now binds the four ADR-0068 D1 identity spellings — current_user, user, ctx.user, os.useralongsidedata, selected out of the host ExpressionProvider's bag rather than copied, so the alias set cannot drift from buildExpressionScope. data stays the draft: the provider's bag also carries a data key meaning its own data scope, and adopting that is the #5926 gap-2 collision the ruling's own text excludes. record, app and features stay unbound so they keep raising the loud warn-once diagnostic instead of resolving to a silent undefinedpredicate.test.ts already pins record.status as a warning, and that pin still passes. No third evaluator: the option filter routes through the same evaluatePredicate the section, field and repeater-row gates use. All five existing call sites in the file now build their ctx through the one buildPredicateCtx, so current_user cannot mean one thing in an option gate and another in the field gate beside it.

The diagnostic itself had to change with the binding: it asserted "the only name is data", which stopped being true the moment identity was bound. It now reads the bound names off the actual scope. A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is in fact correct.

FORK B → B1. Face decisions keep reading the raw option list — resolveFieldFace's hasOptions, resolveColorWidgetKey, and each options.length > 0 branch condition — and only the rendered list is filtered. An emptied set renders an empty picker. Measured consequences of getting this wrong, all three now pinned: the builtin Select would have fallen through to string → Input (a free-text box for "withdraw every option"), MultiSelectWidget would have degraded to its comma-tag editor, and a fully-withdrawn palette would have flipped resolveColorWidgetKey from color-picker to color-input — making the labelling channel #4871 point 4 deliberately fixes in the host predicate-dependent.

FORK C → C1. No pruning. This includes the quiet path: MultiSelectWidget's toggle re-orders the selection against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click — pruning authored metadata through the back door, with no author action that says "remove this".

Rider — FormFieldSpec.options is no longer hand-written. It derives from the spec's SelectOption with its narrowings named in an Omit, per this file's own convention: visibleWhen re-pointed to the local VisibilityPredicate, and defaultdropped and now saying so (nothing on this surface reads it; #6263 owns that key). Two of the spec's five option keys had been dropped by silence — which is exactly how a legally-authored per-option visibleWhen came to parse clean and render inert.

Scope note — one file beyond the dispatched fence

The dispatch fenced SchemaForm.tsx / widgets.tsx / form-spec.ts + tests. This PR also edits predicate.ts (same directory). It is mechanically required by the ruling, which names "the loud warn-once dev diagnostic (predicate.ts machinery)": the ctx type was data-only and had to widen to carry the identity roots, and the diagnostic's hardcoded "the only name is data" had to stop being false. The alternative was a type lie plus a diagnostic that misleads — worse on contract-first grounds. Flagged rather than done silently. (Reviewed and accepted by the dispatching seat: the ruling names that machinery, so the dispatch fence was drawn too narrow.)

Verification

Union re-run at the final commit 06b7d6a3, after the last commit landed — the whole @object-ui/app-shell package suite plus apps/console's type-parity suite: 563 files / 5433 passed, 1 skipped, exit 0.pnpm --filter @object-ui/app-shell type-check exit 0 / 0 errors. lint exit 0 with 0 errors across 1003 linted files (counted from --format json); the single warning attributed to predicate.ts is a pre-existing any annotation present on origin/main too.

  • Ablation, run twice — once per commit, the second against the exact tree that ships. Reverting onlySchemaForm.tsx + widgets.tsx to origin/main (keeping predicate.ts, so the pins fail on behaviour rather than on a missing import) turns 8 of 15 red: every starred assertion, one per consuming control plus the fork pins. The 7 that stay green are the positive controls and the pure buildPredicateCtx block — which is the designed split. Mutation proven on disk on both legs (injected text absent, removed text back); restore proven byte-identical to the HEAD blob by git hash-object, plus an empty git diff HEAD --stat.
  • Every pin asserts an option ABSENT on a false predicate. This evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted; a positive-only pin passes against the unfixed renderer. The shown-when-true cases are kept only as controls.
  • Downstream sweep (--filter '...@object-ui/app-shell', the prefix form = dependents): all 4 consumers green. This mattered — apps/console's FormPage.viewSpec.test.ts asserts an exact type equality between its own form-view type and app-shell's, transitively covering the widened options. Predicted this could break; measured that it does not, because the console derives that type rather than restating it. A first run showed 16 errors that were entirely unbuilt siblings (TS2307); building the console's closure took it to 0.
  • tsconfig.test.json really does compile the new suite — proven, not assumed: it rejected the first fixture (a form type outside FormView's enum, and options typed loosely enough to prove nothing about the widened authoring type). Fixed in the second commit.
  • check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers, check:designer-field-key-parity, changeset:check (no major), check-changeset-presence, check-changeset-overwrite, check:control-bytes, lint:coverage, type-check:coverage — all exit 0.

Deliberately not fixed here — and it bounds the claim

This interim evaluator's in requires an array literal on the right, so ADR-0068's own headline spelling 'admin' in current_user.positions — membership against a path — falls through to the bare-truthy branch and evaluates TRUE regardless of the user, silently.

⚠️ To be exact about the mechanism, because it was briefly misread in review: the text begins with a quote, so it never reaches the in branch at all and the root is never resolved. parseLiteral returns the whole expression verbatim as a truthy string. Binding current_user therefore cannot change the outcome in either direction, and the PATH_SHAPED_LITERAL diagnostic never fired for it either (that one only matches text starting with an identifier character). Measured both ways and root-independent: 'x' in data.tags is equally inert, while the literal-set control data.kind in ['a','b'] discriminates correctly. So this PR neither creates nor worsens it — the behaviour is silently TRUE before and after.

Filed as #6617, which owns closing it; that number is recorded here for reference and is not addressed by this PR. An implementer's feasibility note on its "diagnose only" direction is recorded on that card.

The spellings this change makes genuinely discriminate are the documented subset: path == literal, path != literal, path in [literals], !path, path, &&, ||.

SelectOptionSchema declares a per-option `visibleWhen` (ADR-0068) and is
z.core.$strict, so a *.form.ts carrying one parses clean — but all three
metadata-admin controls that consume `fieldSpec.options` mapped the authored
list straight to items and never read the key. Accepted, stored, shipped,
ignored: ADR-0049's declared-but-unenforced shape, failing in the permissive
direction (the option stayed offered).
Per the maintainer ruling A2 + B1 + C1:
- A2: bind the four ADR-0068 D1 identity spellings alongside `data`, selected
out of the host ExpressionProvider's bag rather than copied. `data` stays the
draft — the provider's conflicting `data` key is NOT adopted. `record`/`app`/
`features` stay unbound so they keep raising the loud diagnostic, which now
reads the bound names off the actual scope instead of claiming "the only name
is `data`". No third evaluator: the filter routes through evaluatePredicate.
- B1: face decisions keep reading the RAW option list; only the rendered list is
filtered, so an emptied set is an empty picker — never the free-text
degradation, never a different widget registration.
- C1: no pruning; MultiSelectWidget's toggle re-orders against the raw list so a
hidden-but-selected value survives.
FormFieldSpec.options now derives from the spec's SelectOption with its
narrowings named in an Omit, instead of hand-mirroring three of five keys.
A pin per consuming control, each asserting an option ABSENT on a false
predicate — the positive case cannot distinguish fail-open from a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
`tsconfig.test.json` compiles this suite, and it caught the fixture typing its
options as `Record<string, unknown>[]` and its `type` as a `'default'` that is
not in FormView's enum. A loose fixture would have let the suite pass while
proving nothing about the AUTHORING type this card widened — the annotation is
itself the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.6 KB3266.6 KB
Main entry chunk (gzip)157.1 KB350 KB
Entry fileindex-Dsv8zCUd.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.90KB114.81KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.98KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)9.30KB3.22KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49), at head 06b7d6a3. Landing gated only on the farm: 29/29 registered, 0 failures, three test shards outstanding. ⛔ Not flipped yet — entry qualification is every check green on the reviewed head.

Why the verification convinces

⭐⭐ The load-bearing insight is the fail-direction one: this evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted. Every pin therefore asserts an option ABSENT on a false predicate, with shown-when-true kept only as controls. A positive-only suite would pass against the unfixed renderer — which is exactly how a legally-authored per-option visibleWhen shipped accepted, stored and inert.

The ablation was designed, not just run: reverting onlySchemaForm.tsx + widgets.tsx while keepingpredicate.ts makes the pins fail on behaviour rather than collapsing on a missing import — 8 red / 7 green, and the 7 green being precisely the positive controls and the pure buildPredicateCtx block is the designed split, not a shortfall. Run twice, once against the exact shipping tree, with mutation proven on disk by grepping injected and removed text separately, and restore proven byte-identical by git hash-object against the HEAD blob.

tsconfig.test.json coverage was proven rather than assumed — it rejected the first fixture with four TS2322 errors naming that file. A suite that isn't compiled is NOT MEASURED, not green, and this one demonstrated it was measured by going red first.

Two red-looking signatures correctly classified as not-red

  • 16 downstream errors on the first console run: 10 TS2307 + 5 TS2882 + 1 TS7006, all unbuilt siblings, none naming a touched file or type. Building the closure took it to 0.
  • check:spec-floors exits 1 with five [no-artifact] findings — it judges built dist/, and only the dependency closure had been built. Proven rather than argued: building app-shell itself dropped it off the list (5 → 4, zero mentions). The remaining four are untouched, unbuilt packages. ⭐ That is NOT MEASURED, not a red gate, and the proof is the right shape — a positive control that moves the count.

Also right: three scripts first returning EXIT=254 were recognised as pnpm "no such script" rather than failures, and re-run under their real names.

The B1 and C1 halves are where the value is

B1 — face decisions keep reading the raw list, so an emptied set renders an empty picker. The measured consequences of getting this wrong are the reason it matters: the builtin Select would have fallen through to string → Input, handing the author a free-text box for "withdraw every option"; MultiSelectWidget would have degraded to a comma-tag editor; a fully-withdrawn palette would have flipped resolveColorWidgetKey to color-input, making the labelling channel #4871 deliberately fixed predicate-dependent. All three now pinned.

C1 — no pruning, ⭐ including the quiet path: MultiSelectWidget's toggle re-orders against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click. That is pruning authored metadata through a back door with no author action that says "remove this" — catching it is the difference between implementing the ruling and implementing its headline.

Rider accepted: deriving FormFieldSpec.options from the spec's SelectOption with narrowings named in an Omit. Two of five keys had been dropped by silence, which is precisely how this defect existed. Naming the narrowing is what stops it recurring; default staying dropped and saying so is the right shape, with #6263 owning that key.

Fence

predicate.ts is outside the three named files in my dispatch order, and the dev declared it rather than doing it quietly. Accepted — my fence was drawn too narrow, not this change too wide. The ruling names the predicate.ts machinery explicitly; the ctx type had to widen, and the diagnostic hardcoded "the only name is data", which the binding made false. ⭐ A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is correct. Nothing else in flight touches that directory.

Retraction, restated here so it cannot be re-derived a third time

I read the in-against-path gap as a regression this PR creates, on the theory that binding current_user removes a previously-loud unbound-root warning. That was wrong and is withdrawn. The expression begins with a quote, so it never matches the in branch, falls to the bare-truthy tail, and parseLiteral returns it verbatim as a truthy string — the root is never resolved at all, so binding cannot move it, and PATH_SHAPED_LITERAL never fired for it either (it matches only text starting with an identifier character). Silent-TRUE before, silent-TRUE after, measured both ways and root-independent. #6617 owns it, correctly filed unlabelled for triage, with the implementer's feasibility note recorded there.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 27, 2026 14:47
@os-sales
os-sales added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 0ea559eAug 27, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6247-option-visiblewhen-metadata-admin branch August 27, 2026 15:00
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Honour per-option `visibleWhen` in the metadata-admin renderer by os-sales · Pull Request #6618 · objectstack-ai/objectui · GitHub
Skip to content

Honour per-option visibleWhen in the metadata-admin renderer - #6618

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin
Aug 27, 2026
Merged

Honour per-option visibleWhen in the metadata-admin renderer#6618
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin

Conversation

@os-sales

@os-salesos-sales commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6247

Authored by Claude Code, session session_01CRJge11jso9TpXRWFt1Z49 (https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49) — recorded here in prose because a body edit rewrites the footer below to its bare form.

SelectOptionSchema declares a per-option visibleWhen (ADR-0068 / #2284) and the schema is z.core.$strict — an undeclared sibling key is refused with unrecognized_keys — so the key is a real declaration and a *.form.ts carrying a per-option predicate parses clean. The metadata-admin renderer never read it: all three controls that consume fieldSpec.options mapped the authored list straight to items. Accepted, stored, shipped, ignored — ADR-0049's declared-but-unenforced shape, failing in the permissive direction (the option stayed offered).

This implements the maintainer ruling A2 + B1 + C1, affirmed three times on the card (2026-08-25T06:24Z batch 4; 2026-08-25T10:32Z upholding A2 against the A1 counter-proposal; 2026-08-27 decision-inbox batch 2 declining the A0 alternative). No fork is re-litigated here.

What changed

FORK A → A2 + A1's diagnostic.SchemaForm's evaluatePredicate ctx now binds the four ADR-0068 D1 identity spellings — current_user, user, ctx.user, os.useralongsidedata, selected out of the host ExpressionProvider's bag rather than copied, so the alias set cannot drift from buildExpressionScope. data stays the draft: the provider's bag also carries a data key meaning its own data scope, and adopting that is the #5926 gap-2 collision the ruling's own text excludes. record, app and features stay unbound so they keep raising the loud warn-once diagnostic instead of resolving to a silent undefinedpredicate.test.ts already pins record.status as a warning, and that pin still passes. No third evaluator: the option filter routes through the same evaluatePredicate the section, field and repeater-row gates use. All five existing call sites in the file now build their ctx through the one buildPredicateCtx, so current_user cannot mean one thing in an option gate and another in the field gate beside it.

The diagnostic itself had to change with the binding: it asserted "the only name is data", which stopped being true the moment identity was bound. It now reads the bound names off the actual scope. A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is in fact correct.

FORK B → B1. Face decisions keep reading the raw option list — resolveFieldFace's hasOptions, resolveColorWidgetKey, and each options.length > 0 branch condition — and only the rendered list is filtered. An emptied set renders an empty picker. Measured consequences of getting this wrong, all three now pinned: the builtin Select would have fallen through to string → Input (a free-text box for "withdraw every option"), MultiSelectWidget would have degraded to its comma-tag editor, and a fully-withdrawn palette would have flipped resolveColorWidgetKey from color-picker to color-input — making the labelling channel #4871 point 4 deliberately fixes in the host predicate-dependent.

FORK C → C1. No pruning. This includes the quiet path: MultiSelectWidget's toggle re-orders the selection against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click — pruning authored metadata through the back door, with no author action that says "remove this".

Rider — FormFieldSpec.options is no longer hand-written. It derives from the spec's SelectOption with its narrowings named in an Omit, per this file's own convention: visibleWhen re-pointed to the local VisibilityPredicate, and defaultdropped and now saying so (nothing on this surface reads it; #6263 owns that key). Two of the spec's five option keys had been dropped by silence — which is exactly how a legally-authored per-option visibleWhen came to parse clean and render inert.

Scope note — one file beyond the dispatched fence

The dispatch fenced SchemaForm.tsx / widgets.tsx / form-spec.ts + tests. This PR also edits predicate.ts (same directory). It is mechanically required by the ruling, which names "the loud warn-once dev diagnostic (predicate.ts machinery)": the ctx type was data-only and had to widen to carry the identity roots, and the diagnostic's hardcoded "the only name is data" had to stop being false. The alternative was a type lie plus a diagnostic that misleads — worse on contract-first grounds. Flagged rather than done silently. (Reviewed and accepted by the dispatching seat: the ruling names that machinery, so the dispatch fence was drawn too narrow.)

Verification

Union re-run at the final commit 06b7d6a3, after the last commit landed — the whole @object-ui/app-shell package suite plus apps/console's type-parity suite: 563 files / 5433 passed, 1 skipped, exit 0.pnpm --filter @object-ui/app-shell type-check exit 0 / 0 errors. lint exit 0 with 0 errors across 1003 linted files (counted from --format json); the single warning attributed to predicate.ts is a pre-existing any annotation present on origin/main too.

  • Ablation, run twice — once per commit, the second against the exact tree that ships. Reverting onlySchemaForm.tsx + widgets.tsx to origin/main (keeping predicate.ts, so the pins fail on behaviour rather than on a missing import) turns 8 of 15 red: every starred assertion, one per consuming control plus the fork pins. The 7 that stay green are the positive controls and the pure buildPredicateCtx block — which is the designed split. Mutation proven on disk on both legs (injected text absent, removed text back); restore proven byte-identical to the HEAD blob by git hash-object, plus an empty git diff HEAD --stat.
  • Every pin asserts an option ABSENT on a false predicate. This evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted; a positive-only pin passes against the unfixed renderer. The shown-when-true cases are kept only as controls.
  • Downstream sweep (--filter '...@object-ui/app-shell', the prefix form = dependents): all 4 consumers green. This mattered — apps/console's FormPage.viewSpec.test.ts asserts an exact type equality between its own form-view type and app-shell's, transitively covering the widened options. Predicted this could break; measured that it does not, because the console derives that type rather than restating it. A first run showed 16 errors that were entirely unbuilt siblings (TS2307); building the console's closure took it to 0.
  • tsconfig.test.json really does compile the new suite — proven, not assumed: it rejected the first fixture (a form type outside FormView's enum, and options typed loosely enough to prove nothing about the widened authoring type). Fixed in the second commit.
  • check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers, check:designer-field-key-parity, changeset:check (no major), check-changeset-presence, check-changeset-overwrite, check:control-bytes, lint:coverage, type-check:coverage — all exit 0.

Deliberately not fixed here — and it bounds the claim

This interim evaluator's in requires an array literal on the right, so ADR-0068's own headline spelling 'admin' in current_user.positions — membership against a path — falls through to the bare-truthy branch and evaluates TRUE regardless of the user, silently.

⚠️ To be exact about the mechanism, because it was briefly misread in review: the text begins with a quote, so it never reaches the in branch at all and the root is never resolved. parseLiteral returns the whole expression verbatim as a truthy string. Binding current_user therefore cannot change the outcome in either direction, and the PATH_SHAPED_LITERAL diagnostic never fired for it either (that one only matches text starting with an identifier character). Measured both ways and root-independent: 'x' in data.tags is equally inert, while the literal-set control data.kind in ['a','b'] discriminates correctly. So this PR neither creates nor worsens it — the behaviour is silently TRUE before and after.

Filed as #6617, which owns closing it; that number is recorded here for reference and is not addressed by this PR. An implementer's feasibility note on its "diagnose only" direction is recorded on that card.

The spellings this change makes genuinely discriminate are the documented subset: path == literal, path != literal, path in [literals], !path, path, &&, ||.

SelectOptionSchema declares a per-option `visibleWhen` (ADR-0068) and is
z.core.$strict, so a *.form.ts carrying one parses clean — but all three
metadata-admin controls that consume `fieldSpec.options` mapped the authored
list straight to items and never read the key. Accepted, stored, shipped,
ignored: ADR-0049's declared-but-unenforced shape, failing in the permissive
direction (the option stayed offered).
Per the maintainer ruling A2 + B1 + C1:
- A2: bind the four ADR-0068 D1 identity spellings alongside `data`, selected
out of the host ExpressionProvider's bag rather than copied. `data` stays the
draft — the provider's conflicting `data` key is NOT adopted. `record`/`app`/
`features` stay unbound so they keep raising the loud diagnostic, which now
reads the bound names off the actual scope instead of claiming "the only name
is `data`". No third evaluator: the filter routes through evaluatePredicate.
- B1: face decisions keep reading the RAW option list; only the rendered list is
filtered, so an emptied set is an empty picker — never the free-text
degradation, never a different widget registration.
- C1: no pruning; MultiSelectWidget's toggle re-orders against the raw list so a
hidden-but-selected value survives.
FormFieldSpec.options now derives from the spec's SelectOption with its
narrowings named in an Omit, instead of hand-mirroring three of five keys.
A pin per consuming control, each asserting an option ABSENT on a false
predicate — the positive case cannot distinguish fail-open from a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
`tsconfig.test.json` compiles this suite, and it caught the fixture typing its
options as `Record<string, unknown>[]` and its `type` as a `'default'` that is
not in FormView's enum. A loose fixture would have let the suite pass while
proving nothing about the AUTHORING type this card widened — the annotation is
itself the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.6 KB3266.6 KB
Main entry chunk (gzip)157.1 KB350 KB
Entry fileindex-Dsv8zCUd.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.90KB114.81KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.98KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)9.30KB3.22KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49), at head 06b7d6a3. Landing gated only on the farm: 29/29 registered, 0 failures, three test shards outstanding. ⛔ Not flipped yet — entry qualification is every check green on the reviewed head.

Why the verification convinces

⭐⭐ The load-bearing insight is the fail-direction one: this evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted. Every pin therefore asserts an option ABSENT on a false predicate, with shown-when-true kept only as controls. A positive-only suite would pass against the unfixed renderer — which is exactly how a legally-authored per-option visibleWhen shipped accepted, stored and inert.

The ablation was designed, not just run: reverting onlySchemaForm.tsx + widgets.tsx while keepingpredicate.ts makes the pins fail on behaviour rather than collapsing on a missing import — 8 red / 7 green, and the 7 green being precisely the positive controls and the pure buildPredicateCtx block is the designed split, not a shortfall. Run twice, once against the exact shipping tree, with mutation proven on disk by grepping injected and removed text separately, and restore proven byte-identical by git hash-object against the HEAD blob.

tsconfig.test.json coverage was proven rather than assumed — it rejected the first fixture with four TS2322 errors naming that file. A suite that isn't compiled is NOT MEASURED, not green, and this one demonstrated it was measured by going red first.

Two red-looking signatures correctly classified as not-red

  • 16 downstream errors on the first console run: 10 TS2307 + 5 TS2882 + 1 TS7006, all unbuilt siblings, none naming a touched file or type. Building the closure took it to 0.
  • check:spec-floors exits 1 with five [no-artifact] findings — it judges built dist/, and only the dependency closure had been built. Proven rather than argued: building app-shell itself dropped it off the list (5 → 4, zero mentions). The remaining four are untouched, unbuilt packages. ⭐ That is NOT MEASURED, not a red gate, and the proof is the right shape — a positive control that moves the count.

Also right: three scripts first returning EXIT=254 were recognised as pnpm "no such script" rather than failures, and re-run under their real names.

The B1 and C1 halves are where the value is

B1 — face decisions keep reading the raw list, so an emptied set renders an empty picker. The measured consequences of getting this wrong are the reason it matters: the builtin Select would have fallen through to string → Input, handing the author a free-text box for "withdraw every option"; MultiSelectWidget would have degraded to a comma-tag editor; a fully-withdrawn palette would have flipped resolveColorWidgetKey to color-input, making the labelling channel #4871 deliberately fixed predicate-dependent. All three now pinned.

C1 — no pruning, ⭐ including the quiet path: MultiSelectWidget's toggle re-orders against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click. That is pruning authored metadata through a back door with no author action that says "remove this" — catching it is the difference between implementing the ruling and implementing its headline.

Rider accepted: deriving FormFieldSpec.options from the spec's SelectOption with narrowings named in an Omit. Two of five keys had been dropped by silence, which is precisely how this defect existed. Naming the narrowing is what stops it recurring; default staying dropped and saying so is the right shape, with #6263 owning that key.

Fence

predicate.ts is outside the three named files in my dispatch order, and the dev declared it rather than doing it quietly. Accepted — my fence was drawn too narrow, not this change too wide. The ruling names the predicate.ts machinery explicitly; the ctx type had to widen, and the diagnostic hardcoded "the only name is data", which the binding made false. ⭐ A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is correct. Nothing else in flight touches that directory.

Retraction, restated here so it cannot be re-derived a third time

I read the in-against-path gap as a regression this PR creates, on the theory that binding current_user removes a previously-loud unbound-root warning. That was wrong and is withdrawn. The expression begins with a quote, so it never matches the in branch, falls to the bare-truthy tail, and parseLiteral returns it verbatim as a truthy string — the root is never resolved at all, so binding cannot move it, and PATH_SHAPED_LITERAL never fired for it either (it matches only text starting with an identifier character). Silent-TRUE before, silent-TRUE after, measured both ways and root-independent. #6617 owns it, correctly filed unlabelled for triage, with the implementer's feasibility note recorded there.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 27, 2026 14:47
@os-sales
os-sales added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 0ea559eAug 27, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6247-option-visiblewhen-metadata-admin branch August 27, 2026 15:00
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Honour per-option `visibleWhen` in the metadata-admin renderer by os-sales · Pull Request #6618 · objectstack-ai/objectui · GitHub
Skip to content

Honour per-option visibleWhen in the metadata-admin renderer - #6618

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin
Aug 27, 2026
Merged

Honour per-option visibleWhen in the metadata-admin renderer#6618
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin

Conversation

@os-sales

@os-salesos-sales commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6247

Authored by Claude Code, session session_01CRJge11jso9TpXRWFt1Z49 (https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49) — recorded here in prose because a body edit rewrites the footer below to its bare form.

SelectOptionSchema declares a per-option visibleWhen (ADR-0068 / #2284) and the schema is z.core.$strict — an undeclared sibling key is refused with unrecognized_keys — so the key is a real declaration and a *.form.ts carrying a per-option predicate parses clean. The metadata-admin renderer never read it: all three controls that consume fieldSpec.options mapped the authored list straight to items. Accepted, stored, shipped, ignored — ADR-0049's declared-but-unenforced shape, failing in the permissive direction (the option stayed offered).

This implements the maintainer ruling A2 + B1 + C1, affirmed three times on the card (2026-08-25T06:24Z batch 4; 2026-08-25T10:32Z upholding A2 against the A1 counter-proposal; 2026-08-27 decision-inbox batch 2 declining the A0 alternative). No fork is re-litigated here.

What changed

FORK A → A2 + A1's diagnostic.SchemaForm's evaluatePredicate ctx now binds the four ADR-0068 D1 identity spellings — current_user, user, ctx.user, os.useralongsidedata, selected out of the host ExpressionProvider's bag rather than copied, so the alias set cannot drift from buildExpressionScope. data stays the draft: the provider's bag also carries a data key meaning its own data scope, and adopting that is the #5926 gap-2 collision the ruling's own text excludes. record, app and features stay unbound so they keep raising the loud warn-once diagnostic instead of resolving to a silent undefinedpredicate.test.ts already pins record.status as a warning, and that pin still passes. No third evaluator: the option filter routes through the same evaluatePredicate the section, field and repeater-row gates use. All five existing call sites in the file now build their ctx through the one buildPredicateCtx, so current_user cannot mean one thing in an option gate and another in the field gate beside it.

The diagnostic itself had to change with the binding: it asserted "the only name is data", which stopped being true the moment identity was bound. It now reads the bound names off the actual scope. A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is in fact correct.

FORK B → B1. Face decisions keep reading the raw option list — resolveFieldFace's hasOptions, resolveColorWidgetKey, and each options.length > 0 branch condition — and only the rendered list is filtered. An emptied set renders an empty picker. Measured consequences of getting this wrong, all three now pinned: the builtin Select would have fallen through to string → Input (a free-text box for "withdraw every option"), MultiSelectWidget would have degraded to its comma-tag editor, and a fully-withdrawn palette would have flipped resolveColorWidgetKey from color-picker to color-input — making the labelling channel #4871 point 4 deliberately fixes in the host predicate-dependent.

FORK C → C1. No pruning. This includes the quiet path: MultiSelectWidget's toggle re-orders the selection against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click — pruning authored metadata through the back door, with no author action that says "remove this".

Rider — FormFieldSpec.options is no longer hand-written. It derives from the spec's SelectOption with its narrowings named in an Omit, per this file's own convention: visibleWhen re-pointed to the local VisibilityPredicate, and defaultdropped and now saying so (nothing on this surface reads it; #6263 owns that key). Two of the spec's five option keys had been dropped by silence — which is exactly how a legally-authored per-option visibleWhen came to parse clean and render inert.

Scope note — one file beyond the dispatched fence

The dispatch fenced SchemaForm.tsx / widgets.tsx / form-spec.ts + tests. This PR also edits predicate.ts (same directory). It is mechanically required by the ruling, which names "the loud warn-once dev diagnostic (predicate.ts machinery)": the ctx type was data-only and had to widen to carry the identity roots, and the diagnostic's hardcoded "the only name is data" had to stop being false. The alternative was a type lie plus a diagnostic that misleads — worse on contract-first grounds. Flagged rather than done silently. (Reviewed and accepted by the dispatching seat: the ruling names that machinery, so the dispatch fence was drawn too narrow.)

Verification

Union re-run at the final commit 06b7d6a3, after the last commit landed — the whole @object-ui/app-shell package suite plus apps/console's type-parity suite: 563 files / 5433 passed, 1 skipped, exit 0.pnpm --filter @object-ui/app-shell type-check exit 0 / 0 errors. lint exit 0 with 0 errors across 1003 linted files (counted from --format json); the single warning attributed to predicate.ts is a pre-existing any annotation present on origin/main too.

  • Ablation, run twice — once per commit, the second against the exact tree that ships. Reverting onlySchemaForm.tsx + widgets.tsx to origin/main (keeping predicate.ts, so the pins fail on behaviour rather than on a missing import) turns 8 of 15 red: every starred assertion, one per consuming control plus the fork pins. The 7 that stay green are the positive controls and the pure buildPredicateCtx block — which is the designed split. Mutation proven on disk on both legs (injected text absent, removed text back); restore proven byte-identical to the HEAD blob by git hash-object, plus an empty git diff HEAD --stat.
  • Every pin asserts an option ABSENT on a false predicate. This evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted; a positive-only pin passes against the unfixed renderer. The shown-when-true cases are kept only as controls.
  • Downstream sweep (--filter '...@object-ui/app-shell', the prefix form = dependents): all 4 consumers green. This mattered — apps/console's FormPage.viewSpec.test.ts asserts an exact type equality between its own form-view type and app-shell's, transitively covering the widened options. Predicted this could break; measured that it does not, because the console derives that type rather than restating it. A first run showed 16 errors that were entirely unbuilt siblings (TS2307); building the console's closure took it to 0.
  • tsconfig.test.json really does compile the new suite — proven, not assumed: it rejected the first fixture (a form type outside FormView's enum, and options typed loosely enough to prove nothing about the widened authoring type). Fixed in the second commit.
  • check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers, check:designer-field-key-parity, changeset:check (no major), check-changeset-presence, check-changeset-overwrite, check:control-bytes, lint:coverage, type-check:coverage — all exit 0.

Deliberately not fixed here — and it bounds the claim

This interim evaluator's in requires an array literal on the right, so ADR-0068's own headline spelling 'admin' in current_user.positions — membership against a path — falls through to the bare-truthy branch and evaluates TRUE regardless of the user, silently.

⚠️ To be exact about the mechanism, because it was briefly misread in review: the text begins with a quote, so it never reaches the in branch at all and the root is never resolved. parseLiteral returns the whole expression verbatim as a truthy string. Binding current_user therefore cannot change the outcome in either direction, and the PATH_SHAPED_LITERAL diagnostic never fired for it either (that one only matches text starting with an identifier character). Measured both ways and root-independent: 'x' in data.tags is equally inert, while the literal-set control data.kind in ['a','b'] discriminates correctly. So this PR neither creates nor worsens it — the behaviour is silently TRUE before and after.

Filed as #6617, which owns closing it; that number is recorded here for reference and is not addressed by this PR. An implementer's feasibility note on its "diagnose only" direction is recorded on that card.

The spellings this change makes genuinely discriminate are the documented subset: path == literal, path != literal, path in [literals], !path, path, &&, ||.

SelectOptionSchema declares a per-option `visibleWhen` (ADR-0068) and is
z.core.$strict, so a *.form.ts carrying one parses clean — but all three
metadata-admin controls that consume `fieldSpec.options` mapped the authored
list straight to items and never read the key. Accepted, stored, shipped,
ignored: ADR-0049's declared-but-unenforced shape, failing in the permissive
direction (the option stayed offered).
Per the maintainer ruling A2 + B1 + C1:
- A2: bind the four ADR-0068 D1 identity spellings alongside `data`, selected
out of the host ExpressionProvider's bag rather than copied. `data` stays the
draft — the provider's conflicting `data` key is NOT adopted. `record`/`app`/
`features` stay unbound so they keep raising the loud diagnostic, which now
reads the bound names off the actual scope instead of claiming "the only name
is `data`". No third evaluator: the filter routes through evaluatePredicate.
- B1: face decisions keep reading the RAW option list; only the rendered list is
filtered, so an emptied set is an empty picker — never the free-text
degradation, never a different widget registration.
- C1: no pruning; MultiSelectWidget's toggle re-orders against the raw list so a
hidden-but-selected value survives.
FormFieldSpec.options now derives from the spec's SelectOption with its
narrowings named in an Omit, instead of hand-mirroring three of five keys.
A pin per consuming control, each asserting an option ABSENT on a false
predicate — the positive case cannot distinguish fail-open from a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
`tsconfig.test.json` compiles this suite, and it caught the fixture typing its
options as `Record<string, unknown>[]` and its `type` as a `'default'` that is
not in FormView's enum. A loose fixture would have let the suite pass while
proving nothing about the AUTHORING type this card widened — the annotation is
itself the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.6 KB3266.6 KB
Main entry chunk (gzip)157.1 KB350 KB
Entry fileindex-Dsv8zCUd.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.90KB114.81KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.98KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)9.30KB3.22KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49), at head 06b7d6a3. Landing gated only on the farm: 29/29 registered, 0 failures, three test shards outstanding. ⛔ Not flipped yet — entry qualification is every check green on the reviewed head.

Why the verification convinces

⭐⭐ The load-bearing insight is the fail-direction one: this evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted. Every pin therefore asserts an option ABSENT on a false predicate, with shown-when-true kept only as controls. A positive-only suite would pass against the unfixed renderer — which is exactly how a legally-authored per-option visibleWhen shipped accepted, stored and inert.

The ablation was designed, not just run: reverting onlySchemaForm.tsx + widgets.tsx while keepingpredicate.ts makes the pins fail on behaviour rather than collapsing on a missing import — 8 red / 7 green, and the 7 green being precisely the positive controls and the pure buildPredicateCtx block is the designed split, not a shortfall. Run twice, once against the exact shipping tree, with mutation proven on disk by grepping injected and removed text separately, and restore proven byte-identical by git hash-object against the HEAD blob.

tsconfig.test.json coverage was proven rather than assumed — it rejected the first fixture with four TS2322 errors naming that file. A suite that isn't compiled is NOT MEASURED, not green, and this one demonstrated it was measured by going red first.

Two red-looking signatures correctly classified as not-red

  • 16 downstream errors on the first console run: 10 TS2307 + 5 TS2882 + 1 TS7006, all unbuilt siblings, none naming a touched file or type. Building the closure took it to 0.
  • check:spec-floors exits 1 with five [no-artifact] findings — it judges built dist/, and only the dependency closure had been built. Proven rather than argued: building app-shell itself dropped it off the list (5 → 4, zero mentions). The remaining four are untouched, unbuilt packages. ⭐ That is NOT MEASURED, not a red gate, and the proof is the right shape — a positive control that moves the count.

Also right: three scripts first returning EXIT=254 were recognised as pnpm "no such script" rather than failures, and re-run under their real names.

The B1 and C1 halves are where the value is

B1 — face decisions keep reading the raw list, so an emptied set renders an empty picker. The measured consequences of getting this wrong are the reason it matters: the builtin Select would have fallen through to string → Input, handing the author a free-text box for "withdraw every option"; MultiSelectWidget would have degraded to a comma-tag editor; a fully-withdrawn palette would have flipped resolveColorWidgetKey to color-input, making the labelling channel #4871 deliberately fixed predicate-dependent. All three now pinned.

C1 — no pruning, ⭐ including the quiet path: MultiSelectWidget's toggle re-orders against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click. That is pruning authored metadata through a back door with no author action that says "remove this" — catching it is the difference between implementing the ruling and implementing its headline.

Rider accepted: deriving FormFieldSpec.options from the spec's SelectOption with narrowings named in an Omit. Two of five keys had been dropped by silence, which is precisely how this defect existed. Naming the narrowing is what stops it recurring; default staying dropped and saying so is the right shape, with #6263 owning that key.

Fence

predicate.ts is outside the three named files in my dispatch order, and the dev declared it rather than doing it quietly. Accepted — my fence was drawn too narrow, not this change too wide. The ruling names the predicate.ts machinery explicitly; the ctx type had to widen, and the diagnostic hardcoded "the only name is data", which the binding made false. ⭐ A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is correct. Nothing else in flight touches that directory.

Retraction, restated here so it cannot be re-derived a third time

I read the in-against-path gap as a regression this PR creates, on the theory that binding current_user removes a previously-loud unbound-root warning. That was wrong and is withdrawn. The expression begins with a quote, so it never matches the in branch, falls to the bare-truthy tail, and parseLiteral returns it verbatim as a truthy string — the root is never resolved at all, so binding cannot move it, and PATH_SHAPED_LITERAL never fired for it either (it matches only text starting with an identifier character). Silent-TRUE before, silent-TRUE after, measured both ways and root-independent. #6617 owns it, correctly filed unlabelled for triage, with the implementer's feasibility note recorded there.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 27, 2026 14:47
@os-sales
os-sales added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 0ea559eAug 27, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6247-option-visiblewhen-metadata-admin branch August 27, 2026 15:00
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Honour per-option `visibleWhen` in the metadata-admin renderer by os-sales · Pull Request #6618 · objectstack-ai/objectui · GitHub
Skip to content

Honour per-option visibleWhen in the metadata-admin renderer - #6618

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin
Aug 27, 2026
Merged

Honour per-option visibleWhen in the metadata-admin renderer#6618
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin

Conversation

@os-sales

@os-salesos-sales commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6247

Authored by Claude Code, session session_01CRJge11jso9TpXRWFt1Z49 (https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49) — recorded here in prose because a body edit rewrites the footer below to its bare form.

SelectOptionSchema declares a per-option visibleWhen (ADR-0068 / #2284) and the schema is z.core.$strict — an undeclared sibling key is refused with unrecognized_keys — so the key is a real declaration and a *.form.ts carrying a per-option predicate parses clean. The metadata-admin renderer never read it: all three controls that consume fieldSpec.options mapped the authored list straight to items. Accepted, stored, shipped, ignored — ADR-0049's declared-but-unenforced shape, failing in the permissive direction (the option stayed offered).

This implements the maintainer ruling A2 + B1 + C1, affirmed three times on the card (2026-08-25T06:24Z batch 4; 2026-08-25T10:32Z upholding A2 against the A1 counter-proposal; 2026-08-27 decision-inbox batch 2 declining the A0 alternative). No fork is re-litigated here.

What changed

FORK A → A2 + A1's diagnostic.SchemaForm's evaluatePredicate ctx now binds the four ADR-0068 D1 identity spellings — current_user, user, ctx.user, os.useralongsidedata, selected out of the host ExpressionProvider's bag rather than copied, so the alias set cannot drift from buildExpressionScope. data stays the draft: the provider's bag also carries a data key meaning its own data scope, and adopting that is the #5926 gap-2 collision the ruling's own text excludes. record, app and features stay unbound so they keep raising the loud warn-once diagnostic instead of resolving to a silent undefinedpredicate.test.ts already pins record.status as a warning, and that pin still passes. No third evaluator: the option filter routes through the same evaluatePredicate the section, field and repeater-row gates use. All five existing call sites in the file now build their ctx through the one buildPredicateCtx, so current_user cannot mean one thing in an option gate and another in the field gate beside it.

The diagnostic itself had to change with the binding: it asserted "the only name is data", which stopped being true the moment identity was bound. It now reads the bound names off the actual scope. A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is in fact correct.

FORK B → B1. Face decisions keep reading the raw option list — resolveFieldFace's hasOptions, resolveColorWidgetKey, and each options.length > 0 branch condition — and only the rendered list is filtered. An emptied set renders an empty picker. Measured consequences of getting this wrong, all three now pinned: the builtin Select would have fallen through to string → Input (a free-text box for "withdraw every option"), MultiSelectWidget would have degraded to its comma-tag editor, and a fully-withdrawn palette would have flipped resolveColorWidgetKey from color-picker to color-input — making the labelling channel #4871 point 4 deliberately fixes in the host predicate-dependent.

FORK C → C1. No pruning. This includes the quiet path: MultiSelectWidget's toggle re-orders the selection against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click — pruning authored metadata through the back door, with no author action that says "remove this".

Rider — FormFieldSpec.options is no longer hand-written. It derives from the spec's SelectOption with its narrowings named in an Omit, per this file's own convention: visibleWhen re-pointed to the local VisibilityPredicate, and defaultdropped and now saying so (nothing on this surface reads it; #6263 owns that key). Two of the spec's five option keys had been dropped by silence — which is exactly how a legally-authored per-option visibleWhen came to parse clean and render inert.

Scope note — one file beyond the dispatched fence

The dispatch fenced SchemaForm.tsx / widgets.tsx / form-spec.ts + tests. This PR also edits predicate.ts (same directory). It is mechanically required by the ruling, which names "the loud warn-once dev diagnostic (predicate.ts machinery)": the ctx type was data-only and had to widen to carry the identity roots, and the diagnostic's hardcoded "the only name is data" had to stop being false. The alternative was a type lie plus a diagnostic that misleads — worse on contract-first grounds. Flagged rather than done silently. (Reviewed and accepted by the dispatching seat: the ruling names that machinery, so the dispatch fence was drawn too narrow.)

Verification

Union re-run at the final commit 06b7d6a3, after the last commit landed — the whole @object-ui/app-shell package suite plus apps/console's type-parity suite: 563 files / 5433 passed, 1 skipped, exit 0.pnpm --filter @object-ui/app-shell type-check exit 0 / 0 errors. lint exit 0 with 0 errors across 1003 linted files (counted from --format json); the single warning attributed to predicate.ts is a pre-existing any annotation present on origin/main too.

  • Ablation, run twice — once per commit, the second against the exact tree that ships. Reverting onlySchemaForm.tsx + widgets.tsx to origin/main (keeping predicate.ts, so the pins fail on behaviour rather than on a missing import) turns 8 of 15 red: every starred assertion, one per consuming control plus the fork pins. The 7 that stay green are the positive controls and the pure buildPredicateCtx block — which is the designed split. Mutation proven on disk on both legs (injected text absent, removed text back); restore proven byte-identical to the HEAD blob by git hash-object, plus an empty git diff HEAD --stat.
  • Every pin asserts an option ABSENT on a false predicate. This evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted; a positive-only pin passes against the unfixed renderer. The shown-when-true cases are kept only as controls.
  • Downstream sweep (--filter '...@object-ui/app-shell', the prefix form = dependents): all 4 consumers green. This mattered — apps/console's FormPage.viewSpec.test.ts asserts an exact type equality between its own form-view type and app-shell's, transitively covering the widened options. Predicted this could break; measured that it does not, because the console derives that type rather than restating it. A first run showed 16 errors that were entirely unbuilt siblings (TS2307); building the console's closure took it to 0.
  • tsconfig.test.json really does compile the new suite — proven, not assumed: it rejected the first fixture (a form type outside FormView's enum, and options typed loosely enough to prove nothing about the widened authoring type). Fixed in the second commit.
  • check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers, check:designer-field-key-parity, changeset:check (no major), check-changeset-presence, check-changeset-overwrite, check:control-bytes, lint:coverage, type-check:coverage — all exit 0.

Deliberately not fixed here — and it bounds the claim

This interim evaluator's in requires an array literal on the right, so ADR-0068's own headline spelling 'admin' in current_user.positions — membership against a path — falls through to the bare-truthy branch and evaluates TRUE regardless of the user, silently.

⚠️ To be exact about the mechanism, because it was briefly misread in review: the text begins with a quote, so it never reaches the in branch at all and the root is never resolved. parseLiteral returns the whole expression verbatim as a truthy string. Binding current_user therefore cannot change the outcome in either direction, and the PATH_SHAPED_LITERAL diagnostic never fired for it either (that one only matches text starting with an identifier character). Measured both ways and root-independent: 'x' in data.tags is equally inert, while the literal-set control data.kind in ['a','b'] discriminates correctly. So this PR neither creates nor worsens it — the behaviour is silently TRUE before and after.

Filed as #6617, which owns closing it; that number is recorded here for reference and is not addressed by this PR. An implementer's feasibility note on its "diagnose only" direction is recorded on that card.

The spellings this change makes genuinely discriminate are the documented subset: path == literal, path != literal, path in [literals], !path, path, &&, ||.

SelectOptionSchema declares a per-option `visibleWhen` (ADR-0068) and is
z.core.$strict, so a *.form.ts carrying one parses clean — but all three
metadata-admin controls that consume `fieldSpec.options` mapped the authored
list straight to items and never read the key. Accepted, stored, shipped,
ignored: ADR-0049's declared-but-unenforced shape, failing in the permissive
direction (the option stayed offered).
Per the maintainer ruling A2 + B1 + C1:
- A2: bind the four ADR-0068 D1 identity spellings alongside `data`, selected
out of the host ExpressionProvider's bag rather than copied. `data` stays the
draft — the provider's conflicting `data` key is NOT adopted. `record`/`app`/
`features` stay unbound so they keep raising the loud diagnostic, which now
reads the bound names off the actual scope instead of claiming "the only name
is `data`". No third evaluator: the filter routes through evaluatePredicate.
- B1: face decisions keep reading the RAW option list; only the rendered list is
filtered, so an emptied set is an empty picker — never the free-text
degradation, never a different widget registration.
- C1: no pruning; MultiSelectWidget's toggle re-orders against the raw list so a
hidden-but-selected value survives.
FormFieldSpec.options now derives from the spec's SelectOption with its
narrowings named in an Omit, instead of hand-mirroring three of five keys.
A pin per consuming control, each asserting an option ABSENT on a false
predicate — the positive case cannot distinguish fail-open from a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
`tsconfig.test.json` compiles this suite, and it caught the fixture typing its
options as `Record<string, unknown>[]` and its `type` as a `'default'` that is
not in FormView's enum. A loose fixture would have let the suite pass while
proving nothing about the AUTHORING type this card widened — the annotation is
itself the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.6 KB3266.6 KB
Main entry chunk (gzip)157.1 KB350 KB
Entry fileindex-Dsv8zCUd.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.90KB114.81KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.98KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)9.30KB3.22KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49), at head 06b7d6a3. Landing gated only on the farm: 29/29 registered, 0 failures, three test shards outstanding. ⛔ Not flipped yet — entry qualification is every check green on the reviewed head.

Why the verification convinces

⭐⭐ The load-bearing insight is the fail-direction one: this evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted. Every pin therefore asserts an option ABSENT on a false predicate, with shown-when-true kept only as controls. A positive-only suite would pass against the unfixed renderer — which is exactly how a legally-authored per-option visibleWhen shipped accepted, stored and inert.

The ablation was designed, not just run: reverting onlySchemaForm.tsx + widgets.tsx while keepingpredicate.ts makes the pins fail on behaviour rather than collapsing on a missing import — 8 red / 7 green, and the 7 green being precisely the positive controls and the pure buildPredicateCtx block is the designed split, not a shortfall. Run twice, once against the exact shipping tree, with mutation proven on disk by grepping injected and removed text separately, and restore proven byte-identical by git hash-object against the HEAD blob.

tsconfig.test.json coverage was proven rather than assumed — it rejected the first fixture with four TS2322 errors naming that file. A suite that isn't compiled is NOT MEASURED, not green, and this one demonstrated it was measured by going red first.

Two red-looking signatures correctly classified as not-red

  • 16 downstream errors on the first console run: 10 TS2307 + 5 TS2882 + 1 TS7006, all unbuilt siblings, none naming a touched file or type. Building the closure took it to 0.
  • check:spec-floors exits 1 with five [no-artifact] findings — it judges built dist/, and only the dependency closure had been built. Proven rather than argued: building app-shell itself dropped it off the list (5 → 4, zero mentions). The remaining four are untouched, unbuilt packages. ⭐ That is NOT MEASURED, not a red gate, and the proof is the right shape — a positive control that moves the count.

Also right: three scripts first returning EXIT=254 were recognised as pnpm "no such script" rather than failures, and re-run under their real names.

The B1 and C1 halves are where the value is

B1 — face decisions keep reading the raw list, so an emptied set renders an empty picker. The measured consequences of getting this wrong are the reason it matters: the builtin Select would have fallen through to string → Input, handing the author a free-text box for "withdraw every option"; MultiSelectWidget would have degraded to a comma-tag editor; a fully-withdrawn palette would have flipped resolveColorWidgetKey to color-input, making the labelling channel #4871 deliberately fixed predicate-dependent. All three now pinned.

C1 — no pruning, ⭐ including the quiet path: MultiSelectWidget's toggle re-orders against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click. That is pruning authored metadata through a back door with no author action that says "remove this" — catching it is the difference between implementing the ruling and implementing its headline.

Rider accepted: deriving FormFieldSpec.options from the spec's SelectOption with narrowings named in an Omit. Two of five keys had been dropped by silence, which is precisely how this defect existed. Naming the narrowing is what stops it recurring; default staying dropped and saying so is the right shape, with #6263 owning that key.

Fence

predicate.ts is outside the three named files in my dispatch order, and the dev declared it rather than doing it quietly. Accepted — my fence was drawn too narrow, not this change too wide. The ruling names the predicate.ts machinery explicitly; the ctx type had to widen, and the diagnostic hardcoded "the only name is data", which the binding made false. ⭐ A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is correct. Nothing else in flight touches that directory.

Retraction, restated here so it cannot be re-derived a third time

I read the in-against-path gap as a regression this PR creates, on the theory that binding current_user removes a previously-loud unbound-root warning. That was wrong and is withdrawn. The expression begins with a quote, so it never matches the in branch, falls to the bare-truthy tail, and parseLiteral returns it verbatim as a truthy string — the root is never resolved at all, so binding cannot move it, and PATH_SHAPED_LITERAL never fired for it either (it matches only text starting with an identifier character). Silent-TRUE before, silent-TRUE after, measured both ways and root-independent. #6617 owns it, correctly filed unlabelled for triage, with the implementer's feasibility note recorded there.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 27, 2026 14:47
@os-sales
os-sales added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 0ea559eAug 27, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6247-option-visiblewhen-metadata-admin branch August 27, 2026 15:00
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Honour per-option `visibleWhen` in the metadata-admin renderer by os-sales · Pull Request #6618 · objectstack-ai/objectui · GitHub
Skip to content

Honour per-option visibleWhen in the metadata-admin renderer - #6618

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin
Aug 27, 2026
Merged

Honour per-option visibleWhen in the metadata-admin renderer#6618
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin

Conversation

@os-sales

@os-salesos-sales commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6247

Authored by Claude Code, session session_01CRJge11jso9TpXRWFt1Z49 (https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49) — recorded here in prose because a body edit rewrites the footer below to its bare form.

SelectOptionSchema declares a per-option visibleWhen (ADR-0068 / #2284) and the schema is z.core.$strict — an undeclared sibling key is refused with unrecognized_keys — so the key is a real declaration and a *.form.ts carrying a per-option predicate parses clean. The metadata-admin renderer never read it: all three controls that consume fieldSpec.options mapped the authored list straight to items. Accepted, stored, shipped, ignored — ADR-0049's declared-but-unenforced shape, failing in the permissive direction (the option stayed offered).

This implements the maintainer ruling A2 + B1 + C1, affirmed three times on the card (2026-08-25T06:24Z batch 4; 2026-08-25T10:32Z upholding A2 against the A1 counter-proposal; 2026-08-27 decision-inbox batch 2 declining the A0 alternative). No fork is re-litigated here.

What changed

FORK A → A2 + A1's diagnostic.SchemaForm's evaluatePredicate ctx now binds the four ADR-0068 D1 identity spellings — current_user, user, ctx.user, os.useralongsidedata, selected out of the host ExpressionProvider's bag rather than copied, so the alias set cannot drift from buildExpressionScope. data stays the draft: the provider's bag also carries a data key meaning its own data scope, and adopting that is the #5926 gap-2 collision the ruling's own text excludes. record, app and features stay unbound so they keep raising the loud warn-once diagnostic instead of resolving to a silent undefinedpredicate.test.ts already pins record.status as a warning, and that pin still passes. No third evaluator: the option filter routes through the same evaluatePredicate the section, field and repeater-row gates use. All five existing call sites in the file now build their ctx through the one buildPredicateCtx, so current_user cannot mean one thing in an option gate and another in the field gate beside it.

The diagnostic itself had to change with the binding: it asserted "the only name is data", which stopped being true the moment identity was bound. It now reads the bound names off the actual scope. A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is in fact correct.

FORK B → B1. Face decisions keep reading the raw option list — resolveFieldFace's hasOptions, resolveColorWidgetKey, and each options.length > 0 branch condition — and only the rendered list is filtered. An emptied set renders an empty picker. Measured consequences of getting this wrong, all three now pinned: the builtin Select would have fallen through to string → Input (a free-text box for "withdraw every option"), MultiSelectWidget would have degraded to its comma-tag editor, and a fully-withdrawn palette would have flipped resolveColorWidgetKey from color-picker to color-input — making the labelling channel #4871 point 4 deliberately fixes in the host predicate-dependent.

FORK C → C1. No pruning. This includes the quiet path: MultiSelectWidget's toggle re-orders the selection against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click — pruning authored metadata through the back door, with no author action that says "remove this".

Rider — FormFieldSpec.options is no longer hand-written. It derives from the spec's SelectOption with its narrowings named in an Omit, per this file's own convention: visibleWhen re-pointed to the local VisibilityPredicate, and defaultdropped and now saying so (nothing on this surface reads it; #6263 owns that key). Two of the spec's five option keys had been dropped by silence — which is exactly how a legally-authored per-option visibleWhen came to parse clean and render inert.

Scope note — one file beyond the dispatched fence

The dispatch fenced SchemaForm.tsx / widgets.tsx / form-spec.ts + tests. This PR also edits predicate.ts (same directory). It is mechanically required by the ruling, which names "the loud warn-once dev diagnostic (predicate.ts machinery)": the ctx type was data-only and had to widen to carry the identity roots, and the diagnostic's hardcoded "the only name is data" had to stop being false. The alternative was a type lie plus a diagnostic that misleads — worse on contract-first grounds. Flagged rather than done silently. (Reviewed and accepted by the dispatching seat: the ruling names that machinery, so the dispatch fence was drawn too narrow.)

Verification

Union re-run at the final commit 06b7d6a3, after the last commit landed — the whole @object-ui/app-shell package suite plus apps/console's type-parity suite: 563 files / 5433 passed, 1 skipped, exit 0.pnpm --filter @object-ui/app-shell type-check exit 0 / 0 errors. lint exit 0 with 0 errors across 1003 linted files (counted from --format json); the single warning attributed to predicate.ts is a pre-existing any annotation present on origin/main too.

  • Ablation, run twice — once per commit, the second against the exact tree that ships. Reverting onlySchemaForm.tsx + widgets.tsx to origin/main (keeping predicate.ts, so the pins fail on behaviour rather than on a missing import) turns 8 of 15 red: every starred assertion, one per consuming control plus the fork pins. The 7 that stay green are the positive controls and the pure buildPredicateCtx block — which is the designed split. Mutation proven on disk on both legs (injected text absent, removed text back); restore proven byte-identical to the HEAD blob by git hash-object, plus an empty git diff HEAD --stat.
  • Every pin asserts an option ABSENT on a false predicate. This evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted; a positive-only pin passes against the unfixed renderer. The shown-when-true cases are kept only as controls.
  • Downstream sweep (--filter '...@object-ui/app-shell', the prefix form = dependents): all 4 consumers green. This mattered — apps/console's FormPage.viewSpec.test.ts asserts an exact type equality between its own form-view type and app-shell's, transitively covering the widened options. Predicted this could break; measured that it does not, because the console derives that type rather than restating it. A first run showed 16 errors that were entirely unbuilt siblings (TS2307); building the console's closure took it to 0.
  • tsconfig.test.json really does compile the new suite — proven, not assumed: it rejected the first fixture (a form type outside FormView's enum, and options typed loosely enough to prove nothing about the widened authoring type). Fixed in the second commit.
  • check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers, check:designer-field-key-parity, changeset:check (no major), check-changeset-presence, check-changeset-overwrite, check:control-bytes, lint:coverage, type-check:coverage — all exit 0.

Deliberately not fixed here — and it bounds the claim

This interim evaluator's in requires an array literal on the right, so ADR-0068's own headline spelling 'admin' in current_user.positions — membership against a path — falls through to the bare-truthy branch and evaluates TRUE regardless of the user, silently.

⚠️ To be exact about the mechanism, because it was briefly misread in review: the text begins with a quote, so it never reaches the in branch at all and the root is never resolved. parseLiteral returns the whole expression verbatim as a truthy string. Binding current_user therefore cannot change the outcome in either direction, and the PATH_SHAPED_LITERAL diagnostic never fired for it either (that one only matches text starting with an identifier character). Measured both ways and root-independent: 'x' in data.tags is equally inert, while the literal-set control data.kind in ['a','b'] discriminates correctly. So this PR neither creates nor worsens it — the behaviour is silently TRUE before and after.

Filed as #6617, which owns closing it; that number is recorded here for reference and is not addressed by this PR. An implementer's feasibility note on its "diagnose only" direction is recorded on that card.

The spellings this change makes genuinely discriminate are the documented subset: path == literal, path != literal, path in [literals], !path, path, &&, ||.

SelectOptionSchema declares a per-option `visibleWhen` (ADR-0068) and is
z.core.$strict, so a *.form.ts carrying one parses clean — but all three
metadata-admin controls that consume `fieldSpec.options` mapped the authored
list straight to items and never read the key. Accepted, stored, shipped,
ignored: ADR-0049's declared-but-unenforced shape, failing in the permissive
direction (the option stayed offered).
Per the maintainer ruling A2 + B1 + C1:
- A2: bind the four ADR-0068 D1 identity spellings alongside `data`, selected
out of the host ExpressionProvider's bag rather than copied. `data` stays the
draft — the provider's conflicting `data` key is NOT adopted. `record`/`app`/
`features` stay unbound so they keep raising the loud diagnostic, which now
reads the bound names off the actual scope instead of claiming "the only name
is `data`". No third evaluator: the filter routes through evaluatePredicate.
- B1: face decisions keep reading the RAW option list; only the rendered list is
filtered, so an emptied set is an empty picker — never the free-text
degradation, never a different widget registration.
- C1: no pruning; MultiSelectWidget's toggle re-orders against the raw list so a
hidden-but-selected value survives.
FormFieldSpec.options now derives from the spec's SelectOption with its
narrowings named in an Omit, instead of hand-mirroring three of five keys.
A pin per consuming control, each asserting an option ABSENT on a false
predicate — the positive case cannot distinguish fail-open from a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
`tsconfig.test.json` compiles this suite, and it caught the fixture typing its
options as `Record<string, unknown>[]` and its `type` as a `'default'` that is
not in FormView's enum. A loose fixture would have let the suite pass while
proving nothing about the AUTHORING type this card widened — the annotation is
itself the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.6 KB3266.6 KB
Main entry chunk (gzip)157.1 KB350 KB
Entry fileindex-Dsv8zCUd.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.90KB114.81KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.98KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)9.30KB3.22KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49), at head 06b7d6a3. Landing gated only on the farm: 29/29 registered, 0 failures, three test shards outstanding. ⛔ Not flipped yet — entry qualification is every check green on the reviewed head.

Why the verification convinces

⭐⭐ The load-bearing insight is the fail-direction one: this evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted. Every pin therefore asserts an option ABSENT on a false predicate, with shown-when-true kept only as controls. A positive-only suite would pass against the unfixed renderer — which is exactly how a legally-authored per-option visibleWhen shipped accepted, stored and inert.

The ablation was designed, not just run: reverting onlySchemaForm.tsx + widgets.tsx while keepingpredicate.ts makes the pins fail on behaviour rather than collapsing on a missing import — 8 red / 7 green, and the 7 green being precisely the positive controls and the pure buildPredicateCtx block is the designed split, not a shortfall. Run twice, once against the exact shipping tree, with mutation proven on disk by grepping injected and removed text separately, and restore proven byte-identical by git hash-object against the HEAD blob.

tsconfig.test.json coverage was proven rather than assumed — it rejected the first fixture with four TS2322 errors naming that file. A suite that isn't compiled is NOT MEASURED, not green, and this one demonstrated it was measured by going red first.

Two red-looking signatures correctly classified as not-red

  • 16 downstream errors on the first console run: 10 TS2307 + 5 TS2882 + 1 TS7006, all unbuilt siblings, none naming a touched file or type. Building the closure took it to 0.
  • check:spec-floors exits 1 with five [no-artifact] findings — it judges built dist/, and only the dependency closure had been built. Proven rather than argued: building app-shell itself dropped it off the list (5 → 4, zero mentions). The remaining four are untouched, unbuilt packages. ⭐ That is NOT MEASURED, not a red gate, and the proof is the right shape — a positive control that moves the count.

Also right: three scripts first returning EXIT=254 were recognised as pnpm "no such script" rather than failures, and re-run under their real names.

The B1 and C1 halves are where the value is

B1 — face decisions keep reading the raw list, so an emptied set renders an empty picker. The measured consequences of getting this wrong are the reason it matters: the builtin Select would have fallen through to string → Input, handing the author a free-text box for "withdraw every option"; MultiSelectWidget would have degraded to a comma-tag editor; a fully-withdrawn palette would have flipped resolveColorWidgetKey to color-input, making the labelling channel #4871 deliberately fixed predicate-dependent. All three now pinned.

C1 — no pruning, ⭐ including the quiet path: MultiSelectWidget's toggle re-orders against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click. That is pruning authored metadata through a back door with no author action that says "remove this" — catching it is the difference between implementing the ruling and implementing its headline.

Rider accepted: deriving FormFieldSpec.options from the spec's SelectOption with narrowings named in an Omit. Two of five keys had been dropped by silence, which is precisely how this defect existed. Naming the narrowing is what stops it recurring; default staying dropped and saying so is the right shape, with #6263 owning that key.

Fence

predicate.ts is outside the three named files in my dispatch order, and the dev declared it rather than doing it quietly. Accepted — my fence was drawn too narrow, not this change too wide. The ruling names the predicate.ts machinery explicitly; the ctx type had to widen, and the diagnostic hardcoded "the only name is data", which the binding made false. ⭐ A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is correct. Nothing else in flight touches that directory.

Retraction, restated here so it cannot be re-derived a third time

I read the in-against-path gap as a regression this PR creates, on the theory that binding current_user removes a previously-loud unbound-root warning. That was wrong and is withdrawn. The expression begins with a quote, so it never matches the in branch, falls to the bare-truthy tail, and parseLiteral returns it verbatim as a truthy string — the root is never resolved at all, so binding cannot move it, and PATH_SHAPED_LITERAL never fired for it either (it matches only text starting with an identifier character). Silent-TRUE before, silent-TRUE after, measured both ways and root-independent. #6617 owns it, correctly filed unlabelled for triage, with the implementer's feasibility note recorded there.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 27, 2026 14:47
@os-sales
os-sales added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 0ea559eAug 27, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6247-option-visiblewhen-metadata-admin branch August 27, 2026 15:00
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Honour per-option `visibleWhen` in the metadata-admin renderer by os-sales · Pull Request #6618 · objectstack-ai/objectui · GitHub
Skip to content

Honour per-option visibleWhen in the metadata-admin renderer - #6618

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin
Aug 27, 2026
Merged

Honour per-option visibleWhen in the metadata-admin renderer#6618
os-sales merged 2 commits into
mainfrom
claude/issue-6247-option-visiblewhen-metadata-admin

Conversation

@os-sales

@os-salesos-sales commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6247

Authored by Claude Code, session session_01CRJge11jso9TpXRWFt1Z49 (https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49) — recorded here in prose because a body edit rewrites the footer below to its bare form.

SelectOptionSchema declares a per-option visibleWhen (ADR-0068 / #2284) and the schema is z.core.$strict — an undeclared sibling key is refused with unrecognized_keys — so the key is a real declaration and a *.form.ts carrying a per-option predicate parses clean. The metadata-admin renderer never read it: all three controls that consume fieldSpec.options mapped the authored list straight to items. Accepted, stored, shipped, ignored — ADR-0049's declared-but-unenforced shape, failing in the permissive direction (the option stayed offered).

This implements the maintainer ruling A2 + B1 + C1, affirmed three times on the card (2026-08-25T06:24Z batch 4; 2026-08-25T10:32Z upholding A2 against the A1 counter-proposal; 2026-08-27 decision-inbox batch 2 declining the A0 alternative). No fork is re-litigated here.

What changed

FORK A → A2 + A1's diagnostic.SchemaForm's evaluatePredicate ctx now binds the four ADR-0068 D1 identity spellings — current_user, user, ctx.user, os.useralongsidedata, selected out of the host ExpressionProvider's bag rather than copied, so the alias set cannot drift from buildExpressionScope. data stays the draft: the provider's bag also carries a data key meaning its own data scope, and adopting that is the #5926 gap-2 collision the ruling's own text excludes. record, app and features stay unbound so they keep raising the loud warn-once diagnostic instead of resolving to a silent undefinedpredicate.test.ts already pins record.status as a warning, and that pin still passes. No third evaluator: the option filter routes through the same evaluatePredicate the section, field and repeater-row gates use. All five existing call sites in the file now build their ctx through the one buildPredicateCtx, so current_user cannot mean one thing in an option gate and another in the field gate beside it.

The diagnostic itself had to change with the binding: it asserted "the only name is data", which stopped being true the moment identity was bound. It now reads the bound names off the actual scope. A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is in fact correct.

FORK B → B1. Face decisions keep reading the raw option list — resolveFieldFace's hasOptions, resolveColorWidgetKey, and each options.length > 0 branch condition — and only the rendered list is filtered. An emptied set renders an empty picker. Measured consequences of getting this wrong, all three now pinned: the builtin Select would have fallen through to string → Input (a free-text box for "withdraw every option"), MultiSelectWidget would have degraded to its comma-tag editor, and a fully-withdrawn palette would have flipped resolveColorWidgetKey from color-picker to color-input — making the labelling channel #4871 point 4 deliberately fixes in the host predicate-dependent.

FORK C → C1. No pruning. This includes the quiet path: MultiSelectWidget's toggle re-orders the selection against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click — pruning authored metadata through the back door, with no author action that says "remove this".

Rider — FormFieldSpec.options is no longer hand-written. It derives from the spec's SelectOption with its narrowings named in an Omit, per this file's own convention: visibleWhen re-pointed to the local VisibilityPredicate, and defaultdropped and now saying so (nothing on this surface reads it; #6263 owns that key). Two of the spec's five option keys had been dropped by silence — which is exactly how a legally-authored per-option visibleWhen came to parse clean and render inert.

Scope note — one file beyond the dispatched fence

The dispatch fenced SchemaForm.tsx / widgets.tsx / form-spec.ts + tests. This PR also edits predicate.ts (same directory). It is mechanically required by the ruling, which names "the loud warn-once dev diagnostic (predicate.ts machinery)": the ctx type was data-only and had to widen to carry the identity roots, and the diagnostic's hardcoded "the only name is data" had to stop being false. The alternative was a type lie plus a diagnostic that misleads — worse on contract-first grounds. Flagged rather than done silently. (Reviewed and accepted by the dispatching seat: the ruling names that machinery, so the dispatch fence was drawn too narrow.)

Verification

Union re-run at the final commit 06b7d6a3, after the last commit landed — the whole @object-ui/app-shell package suite plus apps/console's type-parity suite: 563 files / 5433 passed, 1 skipped, exit 0.pnpm --filter @object-ui/app-shell type-check exit 0 / 0 errors. lint exit 0 with 0 errors across 1003 linted files (counted from --format json); the single warning attributed to predicate.ts is a pre-existing any annotation present on origin/main too.

  • Ablation, run twice — once per commit, the second against the exact tree that ships. Reverting onlySchemaForm.tsx + widgets.tsx to origin/main (keeping predicate.ts, so the pins fail on behaviour rather than on a missing import) turns 8 of 15 red: every starred assertion, one per consuming control plus the fork pins. The 7 that stay green are the positive controls and the pure buildPredicateCtx block — which is the designed split. Mutation proven on disk on both legs (injected text absent, removed text back); restore proven byte-identical to the HEAD blob by git hash-object, plus an empty git diff HEAD --stat.
  • Every pin asserts an option ABSENT on a false predicate. This evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted; a positive-only pin passes against the unfixed renderer. The shown-when-true cases are kept only as controls.
  • Downstream sweep (--filter '...@object-ui/app-shell', the prefix form = dependents): all 4 consumers green. This mattered — apps/console's FormPage.viewSpec.test.ts asserts an exact type equality between its own form-view type and app-shell's, transitively covering the widened options. Predicted this could break; measured that it does not, because the console derives that type rather than restating it. A first run showed 16 errors that were entirely unbuilt siblings (TS2307); building the console's closure took it to 0.
  • tsconfig.test.json really does compile the new suite — proven, not assumed: it rejected the first fixture (a form type outside FormView's enum, and options typed loosely enough to prove nothing about the widened authoring type). Fixed in the second commit.
  • check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers, check:designer-field-key-parity, changeset:check (no major), check-changeset-presence, check-changeset-overwrite, check:control-bytes, lint:coverage, type-check:coverage — all exit 0.

Deliberately not fixed here — and it bounds the claim

This interim evaluator's in requires an array literal on the right, so ADR-0068's own headline spelling 'admin' in current_user.positions — membership against a path — falls through to the bare-truthy branch and evaluates TRUE regardless of the user, silently.

⚠️ To be exact about the mechanism, because it was briefly misread in review: the text begins with a quote, so it never reaches the in branch at all and the root is never resolved. parseLiteral returns the whole expression verbatim as a truthy string. Binding current_user therefore cannot change the outcome in either direction, and the PATH_SHAPED_LITERAL diagnostic never fired for it either (that one only matches text starting with an identifier character). Measured both ways and root-independent: 'x' in data.tags is equally inert, while the literal-set control data.kind in ['a','b'] discriminates correctly. So this PR neither creates nor worsens it — the behaviour is silently TRUE before and after.

Filed as #6617, which owns closing it; that number is recorded here for reference and is not addressed by this PR. An implementer's feasibility note on its "diagnose only" direction is recorded on that card.

The spellings this change makes genuinely discriminate are the documented subset: path == literal, path != literal, path in [literals], !path, path, &&, ||.

SelectOptionSchema declares a per-option `visibleWhen` (ADR-0068) and is
z.core.$strict, so a *.form.ts carrying one parses clean — but all three
metadata-admin controls that consume `fieldSpec.options` mapped the authored
list straight to items and never read the key. Accepted, stored, shipped,
ignored: ADR-0049's declared-but-unenforced shape, failing in the permissive
direction (the option stayed offered).
Per the maintainer ruling A2 + B1 + C1:
- A2: bind the four ADR-0068 D1 identity spellings alongside `data`, selected
out of the host ExpressionProvider's bag rather than copied. `data` stays the
draft — the provider's conflicting `data` key is NOT adopted. `record`/`app`/
`features` stay unbound so they keep raising the loud diagnostic, which now
reads the bound names off the actual scope instead of claiming "the only name
is `data`". No third evaluator: the filter routes through evaluatePredicate.
- B1: face decisions keep reading the RAW option list; only the rendered list is
filtered, so an emptied set is an empty picker — never the free-text
degradation, never a different widget registration.
- C1: no pruning; MultiSelectWidget's toggle re-orders against the raw list so a
hidden-but-selected value survives.
FormFieldSpec.options now derives from the spec's SelectOption with its
narrowings named in an Omit, instead of hand-mirroring three of five keys.
A pin per consuming control, each asserting an option ABSENT on a false
predicate — the positive case cannot distinguish fail-open from a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
`tsconfig.test.json` compiles this suite, and it caught the fixture typing its
options as `Record<string, unknown>[]` and its `type` as a `'default'` that is
not in FormView's enum. A loose fixture would have let the suite pass while
proving nothing about the AUTHORING type this card widened — the annotation is
itself the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.6 KB3266.6 KB
Main entry chunk (gzip)157.1 KB350 KB
Entry fileindex-Dsv8zCUd.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.90KB114.81KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.98KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)9.30KB3.22KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49), at head 06b7d6a3. Landing gated only on the farm: 29/29 registered, 0 failures, three test shards outstanding. ⛔ Not flipped yet — entry qualification is every check green on the reviewed head.

Why the verification convinces

⭐⭐ The load-bearing insight is the fail-direction one: this evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted. Every pin therefore asserts an option ABSENT on a false predicate, with shown-when-true kept only as controls. A positive-only suite would pass against the unfixed renderer — which is exactly how a legally-authored per-option visibleWhen shipped accepted, stored and inert.

The ablation was designed, not just run: reverting onlySchemaForm.tsx + widgets.tsx while keepingpredicate.ts makes the pins fail on behaviour rather than collapsing on a missing import — 8 red / 7 green, and the 7 green being precisely the positive controls and the pure buildPredicateCtx block is the designed split, not a shortfall. Run twice, once against the exact shipping tree, with mutation proven on disk by grepping injected and removed text separately, and restore proven byte-identical by git hash-object against the HEAD blob.

tsconfig.test.json coverage was proven rather than assumed — it rejected the first fixture with four TS2322 errors naming that file. A suite that isn't compiled is NOT MEASURED, not green, and this one demonstrated it was measured by going red first.

Two red-looking signatures correctly classified as not-red

  • 16 downstream errors on the first console run: 10 TS2307 + 5 TS2882 + 1 TS7006, all unbuilt siblings, none naming a touched file or type. Building the closure took it to 0.
  • check:spec-floors exits 1 with five [no-artifact] findings — it judges built dist/, and only the dependency closure had been built. Proven rather than argued: building app-shell itself dropped it off the list (5 → 4, zero mentions). The remaining four are untouched, unbuilt packages. ⭐ That is NOT MEASURED, not a red gate, and the proof is the right shape — a positive control that moves the count.

Also right: three scripts first returning EXIT=254 were recognised as pnpm "no such script" rather than failures, and re-run under their real names.

The B1 and C1 halves are where the value is

B1 — face decisions keep reading the raw list, so an emptied set renders an empty picker. The measured consequences of getting this wrong are the reason it matters: the builtin Select would have fallen through to string → Input, handing the author a free-text box for "withdraw every option"; MultiSelectWidget would have degraded to a comma-tag editor; a fully-withdrawn palette would have flipped resolveColorWidgetKey to color-input, making the labelling channel #4871 deliberately fixed predicate-dependent. All three now pinned.

C1 — no pruning, ⭐ including the quiet path: MultiSelectWidget's toggle re-orders against the raw list, because ordering against the filtered one would have dropped a hidden-but-selected value on the next unrelated click. That is pruning authored metadata through a back door with no author action that says "remove this" — catching it is the difference between implementing the ruling and implementing its headline.

Rider accepted: deriving FormFieldSpec.options from the spec's SelectOption with narrowings named in an Omit. Two of five keys had been dropped by silence, which is precisely how this defect existed. Naming the narrowing is what stops it recurring; default staying dropped and saying so is the right shape, with #6263 owning that key.

Fence

predicate.ts is outside the three named files in my dispatch order, and the dev declared it rather than doing it quietly. Accepted — my fence was drawn too narrow, not this change too wide. The ruling names the predicate.ts machinery explicitly; the ctx type had to widen, and the diagnostic hardcoded "the only name is data", which the binding made false. ⭐ A diagnostic that lies is worse than none — it sends the author to un-write a spelling that is correct. Nothing else in flight touches that directory.

Retraction, restated here so it cannot be re-derived a third time

I read the in-against-path gap as a regression this PR creates, on the theory that binding current_user removes a previously-loud unbound-root warning. That was wrong and is withdrawn. The expression begins with a quote, so it never matches the in branch, falls to the bare-truthy tail, and parseLiteral returns it verbatim as a truthy string — the root is never resolved at all, so binding cannot move it, and PATH_SHAPED_LITERAL never fired for it either (it matches only text starting with an identifier character). Silent-TRUE before, silent-TRUE after, measured both ways and root-independent. #6617 owns it, correctly filed unlabelled for triage, with the implementer's feasibility note recorded there.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 27, 2026 14:47
@os-sales
os-sales added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 0ea559eAug 27, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6247-option-visiblewhen-metadata-admin branch August 27, 2026 15:00
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it

2 participants

@os-sales@claude