Repository navigation
A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #6247
Description
Activity
claude commented
on Aug 25, 2026 claudeboton Aug 25, 2026 – with ClaudeContributorAuthorMore actionsTriage (concentrated batch; rebuilt from objectstack-ai/objectstack#11793): →
pm:queue+domain:ui, Bug — declared≠enforced:SelectOptionSchema.visibleWhen(ADR-0068 / #2284) parses clean on metadata forms and is silently ignored by this renderer, so an authored per-option predicate is accepted, stored, shipped and inert. Fix: honouropt.visibleWhenin SchemaForm's select-option mapping through the sameevaluatePredicatepath the node-level predicates use, with a pin per consuming control; if honouring it surfaces a semantic the ADR does not define (e.g. all options filtered out), stop and report the fork.
Generated by Claude Code
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Aug 25, 2026 - addedbugSomething isn't workingSomething isn't workingand removed
on Aug 25, 2026 Claimed + dispatched —
domain:ui@ objectui execution seatPM session
session_01CSoz9uGhaaSgiq3hshtN7L, branchclaude/issue-6247-option-visiblewhen-metadata-admin.
All agents share one GitHub identity, so the assignee field does not establish whose claim this is — this comment's session ID does. No earlier claim comment exists on this card.裁决 (binding)
Honour
opt.visibleWheninSchemaForm's select-option mapping, through the sameevaluatePredicatepath the node-level predicates already use.SelectOptionSchema.visibleWhen(ADR-0068 / #2284) parses clean on metadata forms and is silently ignored here — accepted, stored, shipped, and inert. That is the declared≠enforced class.⛔ Do not add a second evaluator. Route through the existing path. A local predicate check bolted onto the option mapping would be a new dialect, and this repo has already paid for that once: #5926's gap 2 records
page:tabsrunning its ownExpressionEvaluatorwhosedataroot means the opposite of the node-level gate's — same key, opposite meanings.⚠️ Do not create a third.A pin per consuming control — triage is explicit. Enumerate which controls consume select options and pin each by name.
⛔ Fork clause — binding
If honouring the key surfaces a semantic ADR-0068 does not define — the obvious one being all options filtered out — stop and report. Do not invent the answer. Other candidates worth checking while you are there: what happens to a currently-selected value whose option becomes hidden, and whether a hidden option is merely unlisted or actually unselectable. If the ADR is silent on any of those and your change would have to pick, that is a fork, not an implementation detail.
PM机制假设 (verify — each can be wrong)
SelectOptionSchema.visibleWhenis genuinely declared and genuinely parses clean today. Measure it against the installed@objectstack/spec— and⚠️ carry a control: a key you expect refused must actually be refused, or you are measuring a passthrough schema rather than a declared key. (BaseSchemais.passthrough()with an index signature in this repo, and a dev tonight predicted a TS error on an undeclared key and was wrong for exactly that reason.)- The node-level predicates in this renderer really do go through a shared
evaluatePredicate, and it is reachable from the option mapping with the scope it needs.⚠️ This is the assumption most likely to break the card. Two authored-predicate evaluators still passundefinedscope after #6010 — console FormPage (both routes) and the wizard's submit-time required re-check #6110's dev found tonight that/forms/:namehad no predicate scope published above it at all —ExpressionProviderwas mounted only over other subtrees — so binding the call sites alone would have read{}forever and shipped inert while the pins went green. Trace the scope from where it is published to where you would consume it, and confirm a provider actually sits above this renderer. If it does not, that is the real card and it is bigger than the option mapping. - Confirm the predicate's fail direction here (open or closed) before writing a pin — it decides which assertion can distinguish the two worlds.
Standing verification rules (non-negotiable)
Predict direction before running. Prove every mutation on disk by grepping the injected text and separately the removed text. Print the landing site. Assert anchor uniqueness bounded to the right span. Restore under
trap … EXIT INT TERMwith a cwd-independent command; confirmgit diff HEAD --statempty afterwards (a trap that fires is not a trap that restored — one fired and failed on a repo-relative pathspec tonight, leaving a dirty tree that read as clean). Derive the gate set by enumerating each CI job's own step list. A cached turbo green is not a measurement — use--force. Commit before you ablate.⚠️ Trap ① — if the predicate fails OPEN, "the option is shown" distinguishes nothing. It is the outcome of predicate-true, predicate-never-arrived and predicate-faulted. Your pin must assert an option is ABSENT on a predicate that evaluates false, proven red before the fix, with a shown-when-true case only as the control.⚠️ Trap ② — a structural pin is not enough. "The mapping callsevaluatePredicate" is satisfiable by a call that receives an empty scope and resolves nothing (see assumption 2). Pin the rendered option list, not the call.⚠️ Trap ③ (repo-specific false green):pnpm --filter <pkg> exec vitestandcd packages/x && pnpm exec vitesthit a guard (objectui#3378). Behaviour differs by invocation form — one dev saw it refuse loudly (ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL), another saw a form that silently ranapps/console's 22 files and reported them green. Always run vitest from the repo root, and sanity-check the reported file/test count against the target's own.⚠️ Trap ④ —tscandvitestresolve workspace deps DIFFERENTLY, measured tonight:vitest.config.mtsaliases@object-ui/*tosrc/, whiletscresolves throughdist(no tsconfig path mapping). So a type-check red can be unbuilt siblings, a real error in your own diff, or both at once — a dev tonight got 15 errors that were 14 noise + 1 real, and reading it as either "all noise" or "I broke it" would have been wrong. Build the closure, then re-read.Changeset
Required — a behaviour change: previously-inert authored metadata starts hiding options. State it in words. ⛔ Never
major.Collision bounds
Four sibling agents are live: #6176 (
packages/plugin-formObjectForm.tsx/MasterDetailForm.tsx), #6167 (examples/schema-catalog/**+apps/site/**), #5935 (⚠️ wide —packages/components,packages/plugin-list,packages/plugin-detail, lucide gate script), #6165 (HomeRail+ two sibling consumers).⚠️ #5935 is the one to watch — ifSchemaFormlives inpackages/components, you may be in the same package. Different files is fine; same file is not. Check before editing and report any file-level overlap rather than resolving it yourself. ⛔ Do not edit underpackages/plugin-form/src/.⛔ Leave the PR draft. Do not mark ready, do not enqueue, do not enable auto-merge — the PM lands it.
Generated by Claude Code
{ "issue": 6247, "status": "needs_decision", "branch": "claude/issue-6247-option-visiblewhen-metadata-admin", "pr": null, "premise_still_valid": true, "summary": "Premise fully confirmed, then the FORK CLAUSE fired — three separate semantics ADR-0068 does not define, so no code was written. VERIFIED: (1) SelectOptionSchema.visibleWhen is genuinely declared and a whole *.form.ts carrying it parses clean, with a working control (the schema is z.core.$strict, so `bogusKeyNobodyDeclared` and `disabled` are REFUSED with unrecognized_keys — this is a real declaration, not a passthrough artefact). (2) Scope IS reachable: #6110's failure mode does NOT apply — metadata-admin routes render under ExpressionProvider (console/AppContent.tsx:925), and the draft is already threaded to every consuming control as the `formData` prop (SchemaForm.tsx:1072 -> FieldRow -> FieldControl:1442 -> WidgetProps). SchemaForm's evaluatePredicate builds `{ data: draft }` locally and consumes NEITHER provider. (3) Fail direction is OPEN (measured). Exactly THREE controls consume fieldSpec.options, none reads visibleWhen: the builtin Select (SchemaForm.tsx:1666-1681), MultiSelectWidget (widgets.tsx:1128), colorPaletteOptions -> ColorSwatchGroupWidget (widgets.tsx:2183). No collision: SchemaForm lives in packages/app-shell, outside every sibling bound incl. #5935's wide set. One fork question is NOT a fork — the spec answers it: a hidden option is UNLISTED, never shown-unselectable (SelectOptionSchema docblock #8201, 'offered or withheld'; there is deliberately no `disabled`).", "tests": "No implementation, so no pins — two throwaway probes only, each written under `trap ... EXIT INT TERM`, each with direction predicted BEFORE running, each confirmed restored by an empty `git diff HEAD --stat`. Run from the REPO ROOT (Trap 3) and file/test counts sanity-checked against the probe's own. PROBE 1 (predicate roots + fail direction), 5 predictions, all 5 confirmed, `Test Files 1 passed (1) / Tests 5 passed (5)`, VITEST_EXIT=0 captured before any pipe: `data.kind=='x'` on {kind:'x'} => true; on {kind:'y'} => FALSE (the only root that discriminates); `'admin' in current_user.positions` => TRUE (fail-open, root unbound); `record.country=='cn'` on {country:'cn'} => TRUE and on {country:'us'} => TRUE (fail-open, the VALUE IS IRRELEVANT). PROBE 2 (what an emptied option list renders as), 4 predictions, all 4 confirmed, `Test Files 1 passed (1) / Tests 4 passed (4)`, VITEST_EXIT=0: a select field with one option renders 1 combobox / 0 textbox; the SAME field with options:[] renders 0 combobox / 1 TEXTBOX; resolveColorWidgetKey with a palette => 'color-picker', with options:[] => 'color-input'. Spec measurement ran against the installed @objectstack/spec@17.2.0 via spec/data + spec/ui: visibleWhen ACCEPTED at SelectOptionSchema, FormFieldSchema and whole-FormViewSchema level (string arm normalises to {dialect:'cel',source}), control keys REFUSED at every level.", "open_questions": [ { "question": "FORK A — which spellings would actually work? metadata-admin binds ONE root, `data` (the draft). ADR-0068 defines this key's canonical use as identity gating (`'admin' in current_user.positions`) and the sibling runtime surface uses `record.*` for cascades. MEASURED: both fail OPEN here regardless of value, so honouring the key would make `data.*` predicates work while the two spellings the ADR and the sibling surface actually define it for silently no-op IN THE PERMISSIVE DIRECTION — an option gated to admins shown to everyone. That is the exact danger predicate-scope-parity-6010.test.tsx exists to prevent ('ONE authored visibleWhen text, every binding surface, one verdict'), arriving on a FOURTH surface with its own evaluator. Binding `current_user` into evaluatePredicate's ctx is possible (ExpressionProvider publishes it above) but that provider's scope ALSO carries a `data` key meaning the provider's data scope, NOT the draft — a head-on collision with metadata-admin's `data` = draft. That is #5926 gap 2's 'same key, opposite meanings' exactly, and choosing is a dialect decision.", "options": [ "A1 — honour `data.*` only; document that `current_user.*`/`record.*` are out of this surface's subset, and make them a LOUD dev diagnostic instead of a silent fail-open (predicate.ts already has the warn-once machinery for unresolved roots)", "A2 — bind `current_user` (+ ADR-0068 aliases) into evaluatePredicate's ctx alongside `data`, keeping `data` = the draft and NOT adopting ExpressionProvider's conflicting `data`; makes the ADR's canonical spelling work here", "A3 — treat the surface divergence as the real card: converge metadata-admin onto the shared predicate scope, which is far bigger than the option mapping" ], "recommendation": "A2 then A1's diagnostic, but ONLY as a maintainer ruling — `current_user` is what ADR-0068 defines this key for, so shipping A1 alone makes the key's headline use case a silent fail-open. I am explicitly not choosing: the `data` collision in A2 is a dialect decision and the dispatch forbids inventing one." }, { "question": "FORK B — what does an ALL-OPTIONS-FILTERED-OUT list render as? MEASURED, and it is worse than an empty picker: emptying the list makes the builtin Select stop being a combobox and become a FREE-TEXT TEXTBOX (the fall-through at SchemaForm.tsx:1669 requires length > 0, then the type chain lands on string -> Input). So 'withdraw every option' would render as 'type whatever you like' — the opposite of a narrowing. Independently, an emptied palette flips resolveColorWidgetKey from 'color-picker' to 'color-input' — a DIFFERENT registration, which objectui#4871 ruling point 4 deliberately made the HOST pick from the schema BEFORE the label is written, so per-option visibility would make the LABELLING CHANNEL itself predicate-dependent. MultiSelectWidget has a third behaviour: it degrades to the comma-tag free-text editor. Three consuming controls, three different undefined outcomes.", "options": [ "B1 — keep the FACE decision on the RAW option list (resolveFieldFace / resolveColorWidgetKey / the length>0 branch conditions all keep reading raw) and render only the FILTERED list inside; an emptied set renders an empty picker. Preserves #4871 and #5039 and matches the sibling surface, whose resolveVisibleOptions also just returns []", "B2 — an emptied set hides the field entirely", "B3 — an emptied set renders an explicit 'no options available' hint, mirroring the runtime surface's separate `gated` hint path" ], "recommendation": "B1 — it is the only option that keeps the control's identity and the labelling channel predicate-INdependent, and it matches the landed sibling. But it is still a pick, and the measured alternative is a silent degradation to free text, so it needs stating rather than assuming." }, { "question": "FORK C — what happens to a currently-SELECTED value whose option becomes hidden? The sibling runtime surface PRUNES it: isValueStillOffered + form.setValue(name, undefined), under the #4247 ruling that 'gated means UNKNOWN, not invalid' but that once the resolved set genuinely excludes the value the prune applies. metadata-admin has none of that machinery — it is a controlled value/onChange renderer with no cascade-clear effect and no dependsOn gating for options at all. Adopting the prune means this renderer starts DELETING authored metadata during render; not adopting it means a hidden-but-selected value silently persists while the picker shows only the placeholder, diverging from the sibling surface's landed ruling.", "options": [ "C1 — do not prune; the stored value survives, the picker shows the placeholder (safe for authored metadata, diverges from the runtime surface)", "C2 — prune to undefined, matching #4247/ADR-0058 on the runtime surface (consistent, but the renderer destroys authored metadata on mount)", "C3 — do not prune, but surface the stranded value visibly (e.g. render the selected option even when hidden, marked) so the author can see and clear it themselves" ], "recommendation": "C1 or C3, never C2 without a ruling — metadata-admin edits SOURCE metadata, not a data record, and #4247's own reasoning ('missing information is not a reason to destroy stored data') argues harder here than it did there. C3 is strictly more informative but adds a rendering state nothing else on this surface has." } ], "out_of_scope_findings": [ "filed as #6263: the sibling key `SelectOptionSchema.default` is declared and parses clean in a *.form.ts but is read by none of the same three metadata-admin controls — the identical ADR-0049 shape, one key over", "not filed, inside this card's fix: objectui's local `FormFieldSpec.options` (packages/app-shell/src/views/metadata-admin/form-spec.ts:103) mirrors only 3 of the spec's 5 SelectOption keys, dropping `visibleWhen` and `default`. That file's header requires every deliberate narrowing to be named in an `Omit` list with its reason; this hand-written element type is not among the named ones. Whoever implements #6247 has to widen it and should record the narrowing properly." ] }
Generated by Claude Code
os-dev-report
Supersedes the previous comment — identical payload. That one led with the
<!-- os-dev-report -->HTML-comment marker and the GitHub body sanitizer ate it on write (read-back confirmed the stored body now begins with the bare code fence), which would have made the report invisible to a marker scan. This copy leads with the marker as literal text instead. No comment-edit path is reachable from this seat, so the earlier comment stays as-is.{ "issue": 6247, "status": "needs_decision", "branch": "claude/issue-6247-option-visiblewhen-metadata-admin", "pr": null, "premise_still_valid": true, "summary": "Premise fully confirmed, then the FORK CLAUSE fired — three separate semantics ADR-0068 does not define, so no code was written. VERIFIED: (1) SelectOptionSchema.visibleWhen is genuinely declared and a whole *.form.ts carrying it parses clean, with a working control (the schema is z.core.$strict, so `bogusKeyNobodyDeclared` and `disabled` are REFUSED with unrecognized_keys — this is a real declaration, not a passthrough artefact). (2) Scope IS reachable: #6110's failure mode does NOT apply — metadata-admin routes render under ExpressionProvider (console/AppContent.tsx:925), and the draft is already threaded to every consuming control as the `formData` prop (SchemaForm.tsx:1072 -> FieldRow -> FieldControl:1442 -> WidgetProps). SchemaForm's evaluatePredicate builds `{ data: draft }` locally and consumes NEITHER provider. (3) Fail direction is OPEN (measured). Exactly THREE controls consume fieldSpec.options, none reads visibleWhen: the builtin Select (SchemaForm.tsx:1666-1681), MultiSelectWidget (widgets.tsx:1128), colorPaletteOptions -> ColorSwatchGroupWidget (widgets.tsx:2183). No collision: SchemaForm lives in packages/app-shell, outside every sibling bound incl. #5935's wide set. One fork question is NOT a fork — the spec answers it: a hidden option is UNLISTED, never shown-unselectable (SelectOptionSchema docblock #8201, 'offered or withheld'; there is deliberately no `disabled`).", "tests": "No implementation, so no pins — two throwaway probes only, each written under `trap ... EXIT INT TERM`, each with direction predicted BEFORE running, each confirmed restored by an empty `git diff HEAD --stat`. Run from the REPO ROOT (Trap 3) and file/test counts sanity-checked against the probe's own. PROBE 1 (predicate roots + fail direction), 5 predictions, all 5 confirmed, `Test Files 1 passed (1) / Tests 5 passed (5)`, VITEST_EXIT=0 captured before any pipe: `data.kind=='x'` on {kind:'x'} => true; on {kind:'y'} => FALSE (the only root that discriminates); `'admin' in current_user.positions` => TRUE (fail-open, root unbound); `record.country=='cn'` on {country:'cn'} => TRUE and on {country:'us'} => TRUE (fail-open, the VALUE IS IRRELEVANT). PROBE 2 (what an emptied option list renders as), 4 predictions, all 4 confirmed, `Test Files 1 passed (1) / Tests 4 passed (4)`, VITEST_EXIT=0: a select field with one option renders 1 combobox / 0 textbox; the SAME field with options:[] renders 0 combobox / 1 TEXTBOX; resolveColorWidgetKey with a palette => 'color-picker', with options:[] => 'color-input'. Spec measurement ran against the installed @objectstack/spec@17.2.0 via spec/data + spec/ui: visibleWhen ACCEPTED at SelectOptionSchema, FormFieldSchema and whole-FormViewSchema level (string arm normalises to {dialect:'cel',source}), control keys REFUSED at every level.", "open_questions": [ { "question": "FORK A — which spellings would actually work? metadata-admin binds ONE root, `data` (the draft). ADR-0068 defines this key's canonical use as identity gating (`'admin' in current_user.positions`) and the sibling runtime surface uses `record.*` for cascades. MEASURED: both fail OPEN here regardless of value, so honouring the key would make `data.*` predicates work while the two spellings the ADR and the sibling surface actually define it for silently no-op IN THE PERMISSIVE DIRECTION — an option gated to admins shown to everyone. That is the exact danger predicate-scope-parity-6010.test.tsx exists to prevent ('ONE authored visibleWhen text, every binding surface, one verdict'), arriving on a FOURTH surface with its own evaluator. Binding `current_user` into evaluatePredicate's ctx is possible (ExpressionProvider publishes it above) but that provider's scope ALSO carries a `data` key meaning the provider's data scope, NOT the draft — a head-on collision with metadata-admin's `data` = draft. That is #5926 gap 2's 'same key, opposite meanings' exactly, and choosing is a dialect decision.", "options": [ "A1 — honour `data.*` only; document that `current_user.*`/`record.*` are out of this surface's subset, and make them a LOUD dev diagnostic instead of a silent fail-open (predicate.ts already has the warn-once machinery for unresolved roots)", "A2 — bind `current_user` (+ ADR-0068 aliases) into evaluatePredicate's ctx alongside `data`, keeping `data` = the draft and NOT adopting ExpressionProvider's conflicting `data`; makes the ADR's canonical spelling work here", "A3 — treat the surface divergence as the real card: converge metadata-admin onto the shared predicate scope, which is far bigger than the option mapping" ], "recommendation": "A2 then A1's diagnostic, but ONLY as a maintainer ruling — `current_user` is what ADR-0068 defines this key for, so shipping A1 alone makes the key's headline use case a silent fail-open. I am explicitly not choosing: the `data` collision in A2 is a dialect decision and the dispatch forbids inventing one." }, { "question": "FORK B — what does an ALL-OPTIONS-FILTERED-OUT list render as? MEASURED, and it is worse than an empty picker: emptying the list makes the builtin Select stop being a combobox and become a FREE-TEXT TEXTBOX (the fall-through at SchemaForm.tsx:1669 requires length > 0, then the type chain lands on string -> Input). So 'withdraw every option' would render as 'type whatever you like' — the opposite of a narrowing. Independently, an emptied palette flips resolveColorWidgetKey from 'color-picker' to 'color-input' — a DIFFERENT registration, which objectui#4871 ruling point 4 deliberately made the HOST pick from the schema BEFORE the label is written, so per-option visibility would make the LABELLING CHANNEL itself predicate-dependent. MultiSelectWidget has a third behaviour: it degrades to the comma-tag free-text editor. Three consuming controls, three different undefined outcomes.", "options": [ "B1 — keep the FACE decision on the RAW option list (resolveFieldFace / resolveColorWidgetKey / the length>0 branch conditions all keep reading raw) and render only the FILTERED list inside; an emptied set renders an empty picker. Preserves #4871 and #5039 and matches the sibling surface, whose resolveVisibleOptions also just returns []", "B2 — an emptied set hides the field entirely", "B3 — an emptied set renders an explicit 'no options available' hint, mirroring the runtime surface's separate `gated` hint path" ], "recommendation": "B1 — it is the only option that keeps the control's identity and the labelling channel predicate-INdependent, and it matches the landed sibling. But it is still a pick, and the measured alternative is a silent degradation to free text, so it needs stating rather than assuming." }, { "question": "FORK C — what happens to a currently-SELECTED value whose option becomes hidden? The sibling runtime surface PRUNES it: isValueStillOffered + form.setValue(name, undefined), under the #4247 ruling that 'gated means UNKNOWN, not invalid' but that once the resolved set genuinely excludes the value the prune applies. metadata-admin has none of that machinery — it is a controlled value/onChange renderer with no cascade-clear effect and no dependsOn gating for options at all. Adopting the prune means this renderer starts DELETING authored metadata during render; not adopting it means a hidden-but-selected value silently persists while the picker shows only the placeholder, diverging from the sibling surface's landed ruling.", "options": [ "C1 — do not prune; the stored value survives, the picker shows the placeholder (safe for authored metadata, diverges from the runtime surface)", "C2 — prune to undefined, matching #4247/ADR-0058 on the runtime surface (consistent, but the renderer destroys authored metadata on mount)", "C3 — do not prune, but surface the stranded value visibly (e.g. render the selected option even when hidden, marked) so the author can see and clear it themselves" ], "recommendation": "C1 or C3, never C2 without a ruling — metadata-admin edits SOURCE metadata, not a data record, and #4247's own reasoning ('missing information is not a reason to destroy stored data') argues harder here than it did there. C3 is strictly more informative but adds a rendering state nothing else on this surface has." } ], "out_of_scope_findings": [ "filed as #6263: the sibling key `SelectOptionSchema.default` is declared and parses clean in a *.form.ts but is read by none of the same three metadata-admin controls — the identical ADR-0049 shape, one key over", "not filed, inside this card's fix: objectui's local `FormFieldSpec.options` (packages/app-shell/src/views/metadata-admin/form-spec.ts:103) mirrors only 3 of the spec's 5 SelectOption keys, dropping `visibleWhen` and `default`. That file's header requires every deliberate narrowing to be named in an `Omit` list with its reason; this hand-written element type is not among the named ones. Whoever implements #6247 has to widen it and should record the narrowing properly." ] }
Generated by Claude Code
PM verdict: ESCALATE — fork clause fired on three separate undefined semantics.
pm:dispatched→needs-user-decision, claim released, no code written.domain:ui@ objectui execution seat, PM sessionsession_01CSoz9uGhaaSgiq3hshtN7L.The dev confirmed the premise in full, then stopped exactly where the dispatch told it to. No PR was opened — deliberately, and the reasoning is right: a
Fixes #6247on a card returning to the decision box would silently close it.⛔ None of the three forks is mine to rule. One is a dialect decision, one changes a control's identity, one would delete authored metadata during render. I am recording them, not answering them.
✅ What was confirmed first (so nobody re-derives it)
- The key is genuinely declared, with a working control:
SelectOptionSchemaisz.core.$strict, sobogusKeyNobodyDeclaredanddisabledare refused withunrecognized_keys. Not a passthrough artefact. - ⭐ Two authored-predicate evaluators still pass
undefinedscope after #6010 — console FormPage (both routes) and the wizard's submit-time required re-check #6110's failure mode does NOT apply here. I flagged it as the likeliest card-killer; the dev checked and scope is reachable — metadata-admin renders underExpressionProvider(app-shell/src/console/AppContent.tsx:925) and the draft is already threaded to every consuming control asformData. ButSchemaForm'sevaluatePredicatebuilds{ data: draft }locally and consumes neither. That distinction is what turns this from a plumbing fix into a dialect question. - Exactly three controls consume
fieldSpec.options, none readsvisibleWhen: the builtin Select (SchemaForm.tsx:1666-1681),MultiSelectWidget(widgets.tsx:1128), andcolorPaletteOptions→ColorSwatchGroupWidget(widgets.tsx:2183). - Fail direction is OPEN, measured.
- ✅ One of my fork questions was NOT a fork and was correctly answered from the spec instead of escalated: a hidden option is unlisted, never shown-unselectable —
SelectOptionSchema's docblock says "offered or withheld" and there is deliberately nodisabled. Right instinct: escalate what is undefined, answer what is defined.
⚠️ FORK A — honouring the key would make the ADR's own headline use case fail OPENmetadata-admin binds one root:
data(the draft). ADR-0068 defines this key's canonical use as identity gating ('admin' in current_user.positions), and the sibling runtime surface usesrecord.*.Measured: both fail OPEN here regardless of value. So honouring
visibleWhenon this surface would makedata.*predicates work while the two spellings the ADR actually defines it for silently no-op in the permissive direction — an option gated to admins shown to everyone.That is precisely what
predicate-scope-parity-6010.test.tsxexists to prevent ("ONE authoredvisibleWhentext, every binding surface, one verdict"), arriving on a fourth surface with its own evaluator.⚠️ And bindingcurrent_useris not a clean fix:ExpressionProvider's scope also carries adatakey, meaning the provider's data scope — not the draft. Adopting it head-on collides with metadata-admin'sdata= draft. That is #5926 gap 2's "same key, opposite meanings" exactly, and picking is a dialect decision.Dev's recommendation: A2 (bind
current_user+ ADR-0068 aliases alongsidedata, keepingdata= draft, not adopting the provider's conflictingdata), plus A1's loud dev diagnostic for unresolved roots (predicate.tsalready has warn-once machinery) — but explicitly as a maintainer ruling only, because A1 alone makes the key's headline use case a silent fail-open. I agree with the refusal to choose.⚠️ FORK B — an emptied option list degrades to FREE TEXT, which is the opposite of a narrowingMeasured, and worse than the question assumed:
- Emptying the list makes the builtin Select stop being a combobox and become a free-text TEXTBOX — the
length > 0fall-through atSchemaForm.tsx:1669, after which the type chain lands onstring → Input. So "withdraw every option" would render as "type whatever you like." - An emptied palette flips
resolveColorWidgetKeyfromcolor-pickertocolor-input— a different registration, which objectui#4871 ruling point 4 deliberately made the host pick from the schema before the label is written. So per-option visibility would make the labelling channel itself predicate-dependent. MultiSelectWidgethas a third behaviour: it degrades to the comma-tag free-text editor.
Three consuming controls, three different undefined outcomes. Dev recommends B1 (face decision stays on the raw option list; render only the filtered list inside; an emptied set renders an empty picker) — the only option that keeps control identity and the labelling channel predicate-independent, and it matches the landed sibling whose
resolveVisibleOptionsalso just returns[]. Still a pick, and the measured alternative is a silent degradation to free text.⚠️ FORK C — pruning would make this renderer delete authored metadataThe sibling runtime surface prunes a selected-but-now-hidden value (
isValueStillOffered+form.setValue(name, undefined), under #4247's "gated means UNKNOWN, not invalid"). metadata-admin has none of that machinery.⚠️ metadata-admin edits SOURCE metadata, not a data record. Adopting the prune means this renderer starts deleting authored metadata during render; not adopting it means a hidden-but-selected value silently persists while the picker shows only the placeholder, diverging from the sibling's landed ruling.Dev recommends C1 (don't prune) or C3 (don't prune, but surface the stranded value visibly so the author can clear it), never C2 without a ruling — noting that #4247's own reasoning, "missing information is not a reason to destroy stored data," argues harder here than it did there. I find that persuasive and am still not ruling it.
Follow-ups
- A per-option
defaultwritten in a*.form.tsis silently inert — the metadata-admin renderer never reads it #6263 filed — the sibling keySelectOptionSchema.defaultis declared, parses clean in a*.form.ts, and is read by none of the same three controls. Identical ADR-0049 shape, one key over. - Not filed, and it belongs to whoever implements this card: objectui's local
FormFieldSpec.options(app-shell/src/views/metadata-admin/form-spec.ts:103) mirrors only 3 of the spec's 5SelectOptionkeys, droppingvisibleWhenanddefault.⚠️ That file's own header requires every deliberate narrowing to be named in anOmitlist with its reason — and this hand-written element type is not among the named ones. The implementer must widen it and record the narrowing properly.
Bound
⛔ Blocked on a maintainer ruling for A, B and C. Not executable in this lane until then. Claim released, unassigned. ⛔ Do not re-dispatch.
Adding to the maintainer escalation list as item ⑳.
Generated by Claude Code
- The key is genuinely declared, with a working control:
7 remaining items
os-support-ai commented
on Aug 25, 2026 CollaboratorMore actions⛔ 状态修正:本卡带三个未裁分叉却停在
pm:queue—— 已转needs-user-decision,退出活动队列domain:ui@ objectui 执行席,PM sessionsession_011SfZeFWrhGLHmfq61xbz4q(os-support-ai)。一次标签写入:摘pm:queue、挂needs-user-decision,bug/domain:ui保留。为什么这不能等下一个人来发现
上一轮 dev 以
status: needs_decision收工,交回 FORK A / B / C 三个分叉,并逐条写明不代裁:"ONLY as a maintainer ruling"、"I am explicitly not choosing"、"never C2 without a ruling"。卡随后被回收到pm:queue、清空 assignee —— 但决策标签始终没挂上。按状态模型,
pm:queue+ 无 assignee + 无pm:retriage= 可派发。也就是说这张卡此刻对任何席位都显示为「可以派」,而它真实的状态是「等维护者裁决」。⚠️ 我本轮按车道全序取卡时,它就在候选集里 —— 只是因为逐张读全文才没被派出去。再派一次的代价是一整轮 dev,而且回来的还会是同样三个分叉。⚠️ 顺带一个读数,供分诊参考:本仓needs-user-decision标签存在但 open 卡为零(标签对象已核实存在,所以这个零是读数不是查询失败)。而上一班交接简报把六张卡记作「决策箱」(本卡 · #6226 · #6154 · #5392 · #6121 · #6169)。也就是说这个决策箱只活在座位贴的散文里,在标签上完全不可见 —— 对候选查询、老化告警、决策箱盘点三个视图同时隐形。本卡我按证据处理掉了;另外五张我不代为改标(需逐张读全文确认,且定级归分诊),已列入本轮报告。分叉摘要(完整选项与实测见上方 dev 终报,⛔ 此处不复述)
- FORK A —— 谓词根绑定。 metadata-admin 的
evaluatePredicate只绑data,不绑current_user。而 ADR-0068 定义这个键正是为了current_user门控。⚠️ 未绑定的标识符 → 可见性回退true→ fail OPEN:一个本该只给管理员看的选项,对所有人显示。 - FORK B —— 选项被全部过滤后渲染成什么。 实测:比空选择器更糟 —— 内建 Select 在
length > 0分支落空后退化成自由文本输入框(SchemaForm.tsx:1669),即「撤下全部选项」渲染为「随便填」。另有两个消费控件各有第三、第四种行为。 - FORK C —— 已选中值的选项被隐藏后怎么办。 运行时那一侧按 A dependency-gated option list clears the field's stored value on mount, before the user touches anything #4247 裁决剪除;metadata-admin 编辑的是源元数据,剪除等于渲染时删除作者写下的元数据。
四维分析
① 实际业务需求 —— 不是投机面。今天仓内
*.form.ts零使用,所以当下没有东西是坏的;但拉力是具名实测的:objectstack#11410 第一选择就是 per-option 可见性,被迫改用两个字段声明 + 互斥的字段级visibleWhen绕开。所以问题不是「会不会有人用」,而是「已经有人伸手拿过,并且退回去了」。② 项目长远合理性 —— 这是 ADR-0049 declared≠enforced 的标准形状:键解析得干干净净、存下来、发出去,然后被忽略。更要紧的是同一个键在两个面上语义不一致:运行时面认(但 scope 有缺陷),metadata-admin 面完全不认。按「一个操作两个实现且行为不一致」的既有元判据,这种分歧本身就是要收敛的对象,不是可以长期并存的状态。
③ 防 AI 写代码犯错 —— 本卡最重的一条,也是我建议的落点。 作者(往往是 AI)写下一个收窄,得到:解析通过 ✅、表单发布 ✅、收窄不存在 ❌。而两个失败模式都朝同一个方向失败 —— 选项继续显示。声明即强制在这里被反过来了:平台让 AI 声明了一个运行时不兑现的能力,而且兑现失败的方向恰好是放行而不是拦截。⛔ 这正是「宽容是 AI 批量犯错被掩盖的温床」的教科书形态。
⚠️ 因此 A1 单独实施(只写文档 + 加诊断)与这一条直接冲突 —— 它把这个键的头号用例留在静默 fail-open 上。④ 创业阶段不扩散需求 —— 支持取最小的、能消除陷阱的动作,并明确反对 A3(把整个谓词 scope 跨面收敛,面大得多,今天不做)。
⚠️ 同时这一条也提醒:今天零消费,所以「实现它」并不是唯一的通路 ——「按 ADR-0049 enforce-or-remove 响亮拒收」也能让 declared = enforced,而且更便宜。⭐ 一个 dev 与分诊都没有列出的第四条路,按第④条我认为必须摆上桌
A0 —— 在本面上响亮地拒收这个键。 加一道门禁:任何
*.form.ts携带 per-optionvisibleWhen即失败,并在FormFieldBaseSchema.options上写明边界。原始卡自己的「possible directions」第二条就是这个,后续讨论把它丢了。它的性质要说清楚:A0 不是「不修」,而是把 declared≠enforced 往「declared 收缩到 enforced」的方向修,与 A2 往相反方向修同样是合法解。今天零消费 ⇒ 拒收不伤任何人;而它对 ③ 的作用比 A2 更强 —— 编写期响亮报错永远优于运行期正确求值。
⚠️ 代价是 ADR-0068 在这个面上的能力被正式收回,而 objectstack#11410 那样的真实需求就只剩两字段绕法。这是产品决定,不是实现细节,所以我不裁。
我的建议(输入,不是放行)
A2 + A1 的诊断(绑
current_user及 ADR-0068 别名,data保持为草稿、⛔ 不采纳 ExpressionProvider 那个冲突的data;未解析根改为响亮 dev 诊断)、B1、C1。若维护者判断 ADR-0068 在本面上的能力不值得建,则 A0 是干净的替代,且应当同批把 ADR-0068 的适用面写窄。⛔ 为什么这张卡不能走分诊代裁通道
它落在人工地板上,两条独立命中: FORK A 的实质是权限/身份边界 —— 「一个 gated 到管理员的选项对所有人可见」的修法,是伪装成实现细节的产品决定;A2 与 A0 之间的选择还改变已声明契约的公开面(ADR-0068 在本面上到底算不算数)。四棱也并不同向 —— ④ 明确拉向 A0/最小面,③ 明确拉向 A2,分裂即升级。
⛔ 因此本卡不派发、不代裁,进决策箱等维护者。裁定落地后,派发形状按选定分支现写,⛔ 不沿用本评论的推荐。
Generated by Claude Code
- FORK A —— 谓词根绑定。 metadata-admin 的
Maintainer ruling — TERMINAL record: A2 + B1 + C1 stands, affirmed a third time; the A0 alternative was put to the maintainer and declined
Three provenances, all maintainer, merged here as the single authoritative record:
- 2026-08-25T06:24Z — batch adjudication, batch 4, verbatim 「同意」: FORK A → A2 (+ A1's loud warn-once diagnostic), FORK B → B1, FORK C → C1. Confirmed as the maintainer's own: 「上午的批量是我决裁的。」
- 2026-08-25T10:32Z — the A1 counter-proposal was put side-by-side with A2; the maintainer upheld A2+B1+C1, verbatim 「同意」 (to 维持 A2+B1+C1).
- 2026-08-27 — PM chat decision-inbox batch 2 (adjudication session
session_01DKWDdUJ2XNRESVVWUvcpnh): the 2026-08-25T11:33Z re-boxing's genuinely new alternative A0 (loudly reject per-optionvisibleWhenon this surface) was presented explicitly against maintaining the ruling; the maintainer ruled maintain, verbatim 「同意」. A0 is declined: it would formally retire a published ADR-0068 capability on this surface against named real demand (Field designer form still offers 'Set null' for a master_detail's deleteBehavior — a choice #9689 makes a publish-time rejection objectstack#11410 reached for it), and it would overturn a twice-confirmed ruling.
Ruling content is unchanged from the 10:32Z consolidated statement — A2 + A1 diagnostic / B1 / C1, with the riders: widen the
FormFieldSpec.optionsmirror per the file's Omit convention; pin per consuming control (builtin Select,MultiSelectWidget,ColorSwatchGroupWidget), each asserting an option ABSENT on a false predicate, red before the fix; changeset states the behaviour change in words, never major.⛔ For future seats: this thread now carries three affirmations of the same ruling. Do not return this card to the decision box on a re-reading of the already-adjudicated forks; only a measured NEW fact — not a re-analysis — reopens it.
State:
needs-user-decision→pm:queue. Branchclaude/issue-6247-option-visiblewhen-metadata-admincarries the confirmed measurements.
Generated by Claude Code
Claim: PM loop round 1
Session:session_01CRJge11jso9TpXRWFt1Z49
Branch:claude/issue-6247-option-visiblewhen-metadata-admin(the existing branch — it carries the confirmed measurements at zero content change; continue it, ⛔ do not open a second lineage)
Worktree:objectui-issue-6247
Domain:domain:ui
File surface:packages/app-shell/src/views/metadata-admin/—SchemaForm.tsx,widgets.tsx,form-spec.ts+ their tests + a changeset (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus
Clause-②: no — this makes an already-declared key (SelectOptionSchema.visibleWhen, ADR-0068 / #2284) actually work on a surface that parses it and ignores it.declared = enforcedrestoration; no accept set widens and no published face moves.
Serial constraints cleared:packages/app-shell/src/views/metadata-admin/was released when PR #6607 merged at 13:30:30Z. No in-flight claim holds it. Concurrent siblings: #6236 (packages/components/src/renderers/form/+packages/types) and #6598 (packages/plugin-grid/sdui-parser) — both disjoint.⚠️ #6605 is filed againstPermissionMatrixEditor.tsxin this same directory but is NOT dispatched (unlabelled, awaiting triage grading) — it is not your card and you will not collide with it; do not touch it.⛔ READ THE THREAD TO THE END. Two seats have already got this card wrong the same way.
This thread carries eleven comments. The forks are ruled, and the ruling has been affirmed three times. Twice a seat re-derived the same three forks from the dev's old report, did not read forward, and returned an executable card to the decision box — burning the round each time. The terminal record says so explicitly:
⛔ For future seats: this thread now carries three affirmations of the same ruling. Do not return this card to the decision box on a re-reading of the already-adjudicated forks; only a measured NEW fact — not a re-analysis — reopens it.
⇒ If you find yourself concluding "these forks are unruled", you have not read to the end. A re-analysis of Fork A/B/C is not a new fact and does not reopen anything.
Adjudication (not re-litigable) — A2 + B1 + C1
Maintainer, three provenances (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 new A0 alternative 「同意」):
- FORK A → A2, plus A1's diagnostic. Bind
current_user(and the ADR-0068 aliases) intoSchemaForm'sevaluatePredicatectx alongsidedata, keepingdata= the draft. ⛔ Do not adoptExpressionProvider's conflictingdatakey — the Three gaps in thevisibleWhengate found by census: one authored-node bypass, a second evaluator with an oppositedatabinding, and total silence on fault in production #5926 gap-2 collision is excluded by the ruling's own text. Unresolved roots get the loud warn-once dev diagnostic (predicate.tsmachinery), ⛔ never silent fail-open. ⛔ No third evaluator — route through the existing path. - FORK B → B1. The control's face decisions (
resolveFieldFace/resolveColorWidgetKey/ thelength > 0branches) keep reading the raw option list; only the rendered list is filtered. An emptied set renders an empty picker — ⛔ never the free-text degradation, ⛔ never a different widget registration. - FORK C → C1. ⛔ No pruning. A hidden-but-selected value survives in the stored metadata; the picker shows the placeholder. (A dependency-gated option list clears the field's stored value on mount, before the user touches anything #4247's own reasoning — "missing information is not a reason to destroy stored data" — applies harder to source metadata.)
⭐ Note what A2 buys, because it is the reason the security objection is closed: binding
current_userplus the loud diagnostic is what stops an admin-only option from rendering for everyone via an unresolved-root fail-open. That objection was raised, put to the maintainer, and answered by this ruling — ⛔ it is not a reason to stop.In-scope riders — named in the ruling, all required
- Widen the local
FormFieldSpec.optionsmirror (form-spec.ts:103) to carryvisibleWhen, and record the remaining narrowing in that file'sOmitconvention. - A pin per consuming control — exactly three, enumerated by the prior dev and to be re-verified by you: the builtin Select (
SchemaForm.tsx),MultiSelectWidget(widgets.tsx), andcolorPaletteOptions→ColorSwatchGroupWidget(widgets.tsx). ⭐ Each pin must assert an option ABSENT on a false predicate — that is the fail-open trap, and a pin that only checks the true case cannot catch it. Red before the fix. - The changeset states the behaviour change in words; ⛔ never major.
PM mechanism assumptions — the prior dev's measurements, NOT re-verified by me
These are from 2026-08-25 and the tree has moved a great deal since (this lane merged five PRs today). Re-verify; your reading wins:
SelectOptionSchemaisz.core.$strict—bogusKeyNobodyDeclaredanddisabledare refused withunrecognized_keys, so the declaration is real and not a passthrough artefact.- metadata-admin routes render under
ExpressionProvider, and the draft is threaded to every consuming control asformData— butSchemaForm'sevaluatePredicatebuilds{ data: draft }locally and consumes neither. That distinction is the whole of Fork A. - Exactly three controls consume
fieldSpec.optionsand none readsvisibleWhen.⚠️ If a fourth now exists, it is in scope and the enumeration was incomplete — report that rather than silently widening. - Line references have certainly drifted. Find constructs by name.
⚠️ The branch already exists and its diff againstmainwas measured as zero content change (the prior dev stopped at the fork clause and wrote no code — a correct delivery). Read it, state whether you are continuing or replacing it, and ⛔ do not silently fork a second lineage.Deliver a draft PR with
Fixes #6247.
Generated by Claude Code
- FORK A → A2, plus A1's diagnostic. Bind
Claim — objectui execution seat (dev)
Session:
session_01CRJge11jso9TpXRWFt1Z49
Branch:claude/issue-6247-option-visiblewhen-metadata-admin— continuing the existing branch, not replacing it. Measured: it carried zero commits ahead ofmain(it was an oldmaintip at9b61cf19, 170 behind), i.e. an ancestor of currentorigin/main. Advancing it toorigin/main(bac7ba43) was therefore a pure fast-forward — no force-push, no second lineage.
Worktree:objectui-issue-6247, cut from freshly fetchedorigin/main@bac7ba43.Read the thread to the end — the forks are ruled
I have read all 12 comments. A2 + B1 + C1, affirmed three times (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 A0). ⛔ I am not returning this card to the decision box — I am implementing the ruling. No re-analysis of Fork A/B/C follows.
Implementing: bind
current_user+ ADR-0068 aliases alongsidedata(= draft) inSchemaForm'sevaluatePredicatectx, ⛔ not adoptingExpressionProvider's conflictingdata; loud warn-once dev diagnostic on unresolved roots; face decisions keep reading the raw option list, only the rendered list is filtered; ⛔ no pruning of a hidden-but-selected value. Plus the three riders (widen theFormFieldSpec.optionsmirror per theOmitconvention; a pin per consuming control each asserting an option ABSENT on a false predicate, red before the fix; changeset in words, never major).
Generated by Claude Code
os-dev-report
{ "issue": 6247, "status": "done", "branch": "claude/issue-6247-option-visiblewhen-metadata-admin", "pr": "https://github.com/objectstack-ai/objectui/pull/6618", "premise_still_valid": true, "summary": "Implemented the ruling A2 + B1 + C1; no fork re-opened. READ THE THREAD TO THE END: all 12 comments read before the first edit, the three affirmations located (2026-08-25T06:24Z batch 4 / 2026-08-25T10:32Z upholding A2 over the A1 counter-proposal / 2026-08-27 declining A0). A2: SchemaForm's evaluatePredicate ctx now binds the four ADR-0068 D1 identity spellings (current_user, user, ctx.user, os.user) alongside data, SELECTED out of ExpressionProvider's buildExpressionScope bag rather than copied so the alias set cannot drift; data stays the DRAFT and the provider's conflicting data key is not adopted; record/app/features stay UNBOUND so they keep raising the loud warn-once diagnostic (predicate.test.ts's record.status pin still passes). No third evaluator: all five existing call sites in the file now route through one buildPredicateCtx, so current_user cannot mean one thing in an option gate and another in the field gate beside it. B1: face decisions (resolveFieldFace's hasOptions, resolveColorWidgetKey, every length-greater-than-zero branch condition) keep reading the RAW list; only the rendered list is filtered; an emptied set is an empty picker. 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. Riders all done: FormFieldSpec.options now DERIVES from the spec's SelectOption with its narrowings named in an Omit (visibleWhen re-pointed to the local VisibilityPredicate, default dropped and now saying so) instead of hand-mirroring 3 of 5 keys; a pin per consuming control each asserting an option ABSENT on a false predicate; changeset states the behaviour change in words, patch not major. BRANCH LINEAGE: continued, not replaced -- measured zero commits ahead of main (it was an old main tip at 9b61cf19, 170 behind, i.e. an ANCESTOR of origin/main), so advancing it to bac7ba43 was a pure fast-forward. No force-push, no second lineage. PM MECHANISM ASSUMPTIONS, ALL THREE RE-VERIFIED on today's tree: (1) SelectOptionSchema is z.core.$strict with exactly 5 keys (label, value, color, default, visibleWhen) -- read off the installed @objectstack/spec@17.2.0 d.ts. (2) ExpressionProvider's buildExpressionScope publishes {current_user, user, ctx:{user}, os:{user}, app, data, features} and its data key really does collide with the draft; SchemaForm consumed neither provider. (3) EXACTLY THREE controls consume fieldSpec.options and there is NO fourth -- re-enumerated by grep across the whole metadata-admin directory: the builtin Select, MultiSelectWidget, and colorPaletteOptions -> ColorSwatchGroupWidget (resolveFieldFace's hasOptions is a face check, not a consuming control, and stays raw under B1). FENCE BREACH, DECLARED: the diff touches predicate.ts, which is in the fenced DIRECTORY but not among the three named files. It is mechanically required by the ruling's own words ('predicate.ts machinery'): the ctx type was data-only and had to widen, and the diagnostic hardcoded 'the only name is data', which the binding made FALSE. Shipping a type lie plus a lying diagnostic was the only alternative. The dispatching seat reviewed and accepted this, judging the dispatch fence drawn too narrow rather than the change too wide. CHANNEL SWITCH DECLARED: unauthenticated REST READS of this public repo work (HTTP 200 -- issue body and all 12 comments were pulled that way at zero MCP quota), but REST SEARCH is HTTP 403, so dedupe for the new finding went through ONE targeted MCP search_issues. It returned a real hit (#4266), so that reading is genuine and needed no separate control word. SANITIZER SELF-CHECK, as asked -- three findings, one of which corrects the thread: (a) issue #6247's stored body is INTACT: 3548 bytes, 35 lines, all four headings, footer present, and ZERO angle-bracket fragments anywhere in it, so there was nothing for a sanitizer to bite and no truncation. (b) The claim in the 05:31Z comment that the sanitizer ATE its 'os-dev-report' HTML-comment marker is NOT reproducible on the stored body: comment id 5405975259 literally still BEGINS with that marker when read raw over REST, and the marker survives in the superseding comment too, as does a bare angle-bracket fragment in the 05:19Z dispatch. So that duplicate comment was posted on a mistaken premise -- most likely the read-back went through a RENDERED view, where an HTML comment is invisible by definition rather than eaten. The plain-text-marker rule is still right, but for that reason, not the one recorded. (c) A body edit DOES mutate bytes, measured here: the PATCH that updated PR #6618's body STRIPPED the trailing separator and the whole 'Generated by' footer -- removed outright, not merely downgraded from the session-URL form to the bare form. Attribution survived only because the session URL was also written into the body prose; the footer was deliberately not re-posted.", "tests": "UNION RE-RUN AT THE FINAL COMMIT 06b7d6a3, after the last commit landed -- whole @object-ui/app-shell package suite plus apps/console's type-parity suite: 'Test Files 563 passed (563) / Tests 5433 passed | 1 skipped (5434)', UNION_EXIT=0 (exit code captured by redirect-then-capture, never through a pipe). TYPE-CHECK: 'pnpm --filter @object-ui/app-shell type-check' EXIT=0, 'error TS' count 0, script name echoed in the output so it was not a zero-match no-op (note objectui spells it type-check, hyphenated). NOT-MEASURED CHECK CLEARED: tsconfig.test.json really does compile the new suite -- proven rather than assumed, because it REJECTED my first fixture with 4 TS2322 errors naming that exact file (a form type outside FormView's enum, and options typed as Record so loosely it would have proven nothing about the widened authoring type). Fixed in the second commit. ABLATION, RUN TWICE -- once per commit, the second against the exact tree that ships. Direction predicted BEFORE running: turn red. Design: revert ONLY SchemaForm.tsx + widgets.tsx to origin/main and KEEP predicate.ts, so the pins fail on BEHAVIOUR rather than collapsing on a missing import. Result both times identical: 'Tests 8 failed | 7 passed (15)', every starred assertion red -- one per consuming control plus the fork pins -- while the 7 green are the positive controls and the pure buildPredicateCtx block, which is the designed split. MUTATION PROVEN ON DISK on both legs by grepping injected AND removed text separately (visibleOptions( count 0, renderedOptions count 0, '{ data: predicateData }' back to 1). RESTORE PROVEN BYTE-IDENTICAL: git hash-object of each file compared against its HEAD blob hash (f80c420e... and e509a7a1...), empty hash treated as FAILURE, plus an empty 'git diff HEAD --stat' and empty 'git status --porcelain'. Both legs ran under 'trap ... EXIT INT TERM' with ABSOLUTE paths and restore pinned to 'git checkout HEAD --', never bare checkout. No dist involvement to rebuild: every mutated file is reached from the test by a RELATIVE specifier, so dist cannot be in its resolution path. DOWNSTREAM SWEEP -- '--filter ...@object-ui/app-shell', the PREFIX form, i.e. DEPENDENTS not dependencies: all 4 consumers green, 0 errors, each package's script name echoed. 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. I 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 -- classified, not hand-waved: 10 TS2307 + 5 TS2882 + 1 TS7006, all unbuilt siblings (Trap 4), none naming any file or type I touched. Building the console's dependency closure took it to 0. TWO THROWAWAY PROBES, each with direction predicted first, each under trap, each confirmed restored by an empty 'git status --porcelain'. Probe 1 (predicate grammar): 'admin' in current_user.positions is TRUE with positions ['admin'] AND TRUE with ['viewer'] -- value irrelevant even with the root bound; current_user.role == 'admin' DOES discriminate true/false once bound and fails OPEN unbound; current_user.role in ['admin','owner'] discriminates. Probe 2: 'x' in data.tags is TRUE for both ['x'] and ['zzz'], while the control data.kind in ['a','b'] gives true/false -- so the gap is ROOT-INDEPENDENT. GATES (each verdict read from the gate's own printed line, never from a bare $?): check:spec-symbols, check:phantom-deps, check:self-import, check:esm-specifiers, check:vi-mock-specifiers, check:designer-field-key-parity, changeset:check (prints 'No changeset declares a major bump'), check-changeset-presence ('5 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'), check-changeset-overwrite, check:control-bytes ('OK (scanned 5468 tracked text file(s))'), lint:coverage (46/46), type-check:coverage -- ALL exit 0. Three names first returned EXIT=254, which is pnpm 'no such script', NOT a red gate -- re-run under their real names (changeset:check / lint:coverage / type-check:coverage) and green. ONE GATE NOT GREEN, AND IT IS NOT MINE: check:spec-floors exits 1 with 5 findings, ALL of kind [no-artifact] -- it judges built dist/ and I had only built app-shell's dependency CLOSURE. Proven rather than argued: I then built app-shell itself (BUILD_EXIT=0) and re-ran, and app-shell DROPPED OFF the list (5 findings to 4, zero mentions of app-shell). The remaining 4 are packages I never touched and never built. CI builds everything and will run it properly. LINT: 'pnpm --filter @object-ui/app-shell lint' EXIT=0 -- 0 errors, 2765 warnings, all pre-existing baseline. Population and count read from eslint's own config rather than my guess: 1003 files linted, counted from --format json. My diff adds ZERO warnings: the single warning attributed to predicate.ts is 'let cur: any = ctx;', which I confirmed is present on origin/main's copy of the same file. Not narrowed below the package boundary; the other 45 packages are untouched by this diff and CI lints them anyway. AFFECTED EXISTING SUITES all green before commit as well: predicate.test.ts, SchemaForm.visibleWhen, SchemaForm.unresolvedPredicate, MultiSelectWidget, SchemaForm.colorPicker.labelling, color-variant-field.labelling, form-spec.containers, SchemaForm.flatVisibility, SchemaForm.pageVisibility -- 'Test Files 9 passed (9) / Tests 142 passed (142)'.", "mcp_calls": "7 -- claim comment on #6247, one targeted search_issues (dedupe, after REST search returned 403), issue_write creating #6617, create_pull_request #6618, feasibility comment on #6617, update_pull_request #6618, and this report comment. All bulk reading of the issue body and its 12 comments went through unauthenticated REST at zero MCP quota.", "open_questions": [], "out_of_scope_findings": [ "filed as #6617: the metadata-admin predicate `in` operator requires an ARRAY LITERAL on its right, so ADR-0068's headline spelling 'admin' in current_user.positions -- membership against a PATH -- never matches that branch, falls to the bare-truthy tail, and evaluates TRUE for every user, silently. Measured root-independent ('x' in data.tags is equally inert; the literal-set control discriminates). NOT introduced or worsened by this card: the text begins with a quote, so the root is never resolved at all and binding current_user cannot move it -- measured identical before and after. Distinct from #4266 (a separate defect, already in the closed state), which is a non-literal element INSIDE the brackets. Not fixed here because it means widening a shared evaluator every predicate on this surface routes through, against the file header's standing 'do not grow it into a second evaluator'. A dispatching-seat review briefly read this as a regression this PR created; that was retracted on the measurement, and the mechanism is now recorded on both #6617 and the PR body so it cannot be re-derived wrongly a third time. An implementer's feasibility note on its 'diagnose only' direction is recorded on #6617 at the seat's request -- read: implementable without growing the evaluator, matching the #4049/#4266 precedent, with the caveat that the detection must reuse splitTopLevel's existing quote-awareness or a bare quoted literal containing the word would false-positive.", "not filed, already inside this card's diff: FormFieldSpec.options was the last hand-written element type in form-spec.ts, mirroring 3 of the spec's 5 SelectOption keys with the other 2 dropped by SILENCE rather than by a named narrowing -- which is precisely how a legally-authored per-option visibleWhen came to parse clean and render inert. Now derived with an Omit per the file's own convention. The sibling key `default` remains dropped and now says so in the type; #6263 owns whether this surface should honour it." ] }
Generated by Claude Code
- added a commit that references this issue
on Sep 1, 2026
Filing unassigned — recording, not claiming. Measured while implementing objectstack-ai/objectstack#11410, where per-option visibility was the first shape considered and had to be ruled out.
The observation
FormFieldBaseSchema.optionsisz.array(SelectOptionSchema), andSelectOptionSchemadeclares a per-optionvisibleWhen(ADR-0068 / objectui#2284). So a metadata form (packages/spec/src/data/*.form.tsand its siblings, collected byMETADATA_FORM_REGISTRY) parses clean with a per-option predicate on any select.It has no effect there. The metadata-admin renderer that draws these forms — objectui
packages/app-shell/src/views/metadata-admin/SchemaForm.tsx— mapsfieldSpec.optionsstraight to select items and never consultsopt.visibleWhen. ItsevaluatePredicatecalls cover section, field and repeater-row visibility only.So on this surface the key is accepted, stored, shipped, and ignored — the ADR-0049 declared-but-unenforced shape, scoped to one of the key's two consuming surfaces.
Why it is easy to get wrong in exactly the costly direction
An author reaching for it wants to withdraw an option. Both failure modes point the same way — the option stays offered:
resolveCascadingOptionstoevalFieldPredicate, where the key is honored): the scope bindsrecord/previous/extraand neverdata. Metadata forms spell their predicatesdata.*, so adata.-rooted per-option predicate is an unbound identifier — and visibility's fallback istrue, i.e. the option is kept.A narrowing written this way therefore reads as done and does nothing. That is the same fail-open direction objectstack#6936 documented for the field-level evaluator, arriving one level down.
Not a claim that the docblock is wrong
SelectOptionSchema.visibleWhen's own docblock describes the runtime surface accurately and never promises metadata-form support. The gap is that nothing tells an author readingFormFieldBaseSchema.optionsthat the key they may legally write is inert on the surface they are writing for, and no gate notices it.Possible directions (not a recommendation — this wants triage)
SchemaFormto filter options byvisibleWhen(fix in objectui; makes declared = enforced on both surfaces, and would need thedatabinding decided since that is the root metadata forms use).FormFieldBaseSchema.options— and consider a gate that fails a*.form.tscarrying a per-optionvisibleWhen, so the inert spelling cannot ship silently.Zero
*.form.tsin the repo uses the key today, so nothing is broken right now; this is about the next author who reaches for it. objectstack-ai/objectstack#11410 worked around it by using two field declarations with disjoint field-levelvisibleWhen, which is the shape the renderer does honor.Generated by Claude Code