Repository navigation
Console FormPage (the standalone /forms and internal FormView renderer) never evaluates visibleWhen/visibleOn — objectui#2212 was fixed in the OTHER form renderer #5594
Description
Activity
huangyiirene commented
on Aug 21, 2026 CollaboratorMore actionsTriage: lands in
apps/console/src/components/FormPage.tsx—domain:ui, type Bug, promoted topm:queue. User-reachable on the public/forms/:slugroute: spec-legalvisibleWhen/visibleOnmetadata (ADR-0089) renders unconditionally, fail-open and silent — that violates the declared contract, and the sibling ruling already exists. Fix direction inherits #2212's ruling verbatim (same family, same reason): route the predicate through the canonical engine —evalFieldPredicateinpackages/coreevaluator/fieldRules.ts, which already accepts both the string anddialect/sourceobject arms. ⛔ Scope fence: do NOT attempt the "fold FormPage onto the plugin-form chain" architecture here — the second-renderer question is real but belongs with the #5596 convergence track; this card is the predicate wiring plus a regression pin that lives with THIS renderer (the #2212 pin cannot see it, which is how the gap survived).
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingdomain: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 21, 2026 Claim —
domain:uiexecution seat (#5560), round 8.- Session:
session_012u2pRjcqAYtoEjgr3wwhnK - Branch:
claude/issue-5594-formpage-visible-predicates - Label:
pm:queue→pm:dispatched
The triage scope fence is carried into the dispatch verbatim: no "fold FormPage onto the plugin-form chain" work here. This card is the predicate wiring in this renderer plus a regression pin that lives with it; the second-renderer convergence question stays with the #5596 track.
Generated by Claude Code
- Session:
- added a commit that references this issue
on Aug 21, 2026 {
"issue": 5594,
"status": "done",
"branch": "claude/issue-5594-formpage-visible-predicates",
"pr": "#5624",
"pr_url": "#5624",
"head_sha": "93187c334",
"premise_still_valid": true,
"premise_note": "Holds, with one correction to the card's own evidence. The card quotedgrep -c 'visibleWhen\\|visibleOn' FormPage.tsx -> 0on ref cad512f. On today's main (8c87f05) that same bare grep returns 1 — the hit is a PROSE MENTION inside the docblock #5542 added, not a read. Re-scoped to a property read the count is still 0, so the defect is real and unchanged; only the card's probe had gone stale.",
"summary": "FormPage.tsx (the console's second, independent form renderer, serving both /f/:slug and /forms/:name) now evaluates the FormView field visibility predicate. The predicate is routed through the canonical engine — evalFieldPredicate in @object-ui/core evaluator/fieldRules.ts — which is #2212's ruling applied verbatim rather than a second predicate semantics: same engine, same wire shapes (bare CEL string and {dialect, source}), same record./previous. scope, same fail-open-but-loud behaviour, and the same canonical-first precedence (visibleWhen ?? visibleOn) as both sibling readers. buildSections carries the resolved predicate onto RenderableField with its type DERIVED from FormFieldSpec['visibleWhen'] rather than restated; a new exported isFieldVisible answers the static hidden flag and the predicate in one verdict; the render filter calls it. The regression pin lives with THIS renderer. FormPage was NOT folded onto the plugin-form chain.",
"files_changed": [
"apps/console/src/components/FormPage.tsx",
"apps/console/src/components/FormPage.visibleWhen.test.tsx (new)",
".changeset/console-formpage-visible-predicates-5594.md (new)"
],
"what_changed": "RenderableField gainsvisibleWhen?: FormFieldSpec['visibleWhen'](type derived, not restated — restating it would be a third description of one contract, the class #5542 closed in this very file). buildSections resolvesoverride.visibleWhen ?? override.visibleOnonto the row (carried, not evaluated: the verdict depends on live values, the rows are memoized on the loaded spec). New exportedisFieldVisible(field, values, previous?)returns false for a statichidden: trueand otherwise callsevalFieldPredicate(field.visibleWhen, values, /* fallback */ true, previous ?? undefined, undefined, { context: \"visibleWhen of field 'NAME'\" }). The render filter changed from.filter((f) => !f.hidden)to.filter((f) => isFieldVisible(f, values, loaded.record)). Deliberately unchanged: a predicate-hidden field still submits its value (a rendering rule in both renderers; #2212's fix returns null at render and clears stale ERRORS, never values) — pinned as a control rather than assumed.",
"evidence": {
"predicate_absent_probe": "On origin/main (8c87f05):grep -cE '\\b(f|field|override|sec)\\.(visibleWhen|visibleOn)\\b' apps/console/src/components/FormPage.tsx-> 0. A bare name grep returns 1, but that single hit is line 287, prose inside a docblock, not a read — so the read-scoped probe is the honest one.",
"control_probe": "Same file, same regex shape, same property-read form, the static flag this renderer DOES read:grep -cE '\\b(f|field|override|sec)\\.(hidden)\\b' apps/console/src/components/FormPage.tsx-> 2. Non-zero at identical scope, so the 0 above is a missing read and not a bad search.",
"helper_signature_verified": "Read out of the BUILT dist/.d.ts that apps/console's tsc actually resolves (no path alias in apps/console/tsconfig.json, so @object-ui/core resolves through node_modules to dist), afterpnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build(exit 0, 146s). packages/core/dist/evaluator/fieldRules.d.ts:63 —export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined | null, record: Record[string, unknown], fallback: boolean, previous?: Record[string, unknown], scope?: Record[string, unknown], diagnostic?: FieldPredicateDiagnostic): boolean;— reachable from the package root viaexport * from './evaluator/index.js'(packages/core/dist/index.d.ts:26). Spelling confirmed asevalFieldPredicatein evaluator/fieldRules.ts, exactly as the dispatch named it.",
"test_proves_regression": "Reverse verification, MEASURED not predicted. FormPage.tsx reverted to origin/main with the new pin left in place:Tests 11 failed | 1 passed (12). Mutation proven on disk in BOTH legs by anchored greps on the text being changed (mutation leg: isFieldVisible 0, evalFieldPredicate 0, old!f.hiddenfilter 1; restore leg: isFieldVisible 4, old filter 0,git status --porcelainempty). The script carriestrap RESTORE EXIT INT TERM. No build leg is involved and that is stated rather than assumed: the test imports the subject as a relative source path (./FormPage), not through a package exports -> dist, so no stale artifact can be read. The one green is the control that must be green — 'a field with NO predicate at all still renders' — without which every absence assertion would be satisfied by a renderer that draws nothing. The two cases named CONTROL are red pre-fix only because each must first establish that the field IS hidden; their control halves (fail-open direction, submitted payload) are unaffected. The test docblock carries the full per-test breakdown and was corrected to the measured result after the run (the first draft predicted those two as green-both-sides, which the measurement falsified)."
},
"changeset": {
"present": true,
"file": ".changeset/console-formpage-visible-predicates-5594.md",
"why": "@object-ui/console: patch. apps/console is an application but sits in the release fixed group, so src/ changes need one — pre-decided by the dispatch and confirmed by the gate: check-changeset-presence reports '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'. No packages/** file is touched, so the 'does dist/.d.ts move?' criterion does not arise for this change; nothing was assumed about emitted output because nothing published was edited."
},
"tests": {
"command": "All from the REPO ROOT (package-cwd vitest is refused by the repo's own guard, objectui#3378), every heavy leg serialized through the container's shared verify lock:npx vitest run apps/console --maxWorkers=2andpnpm --filter @object-ui/console type-check. Union re-run AFTER the final commit at 93187c3 with a clean worktree.",
"result": "vitest:Test Files 66 passed (66)·Tests 722 passed (722)(exit 0). type-check: exit 0, script name echoed in the output (tsc --noEmit && tsc -b tsconfig.node.json --force) so it was not a zero-match silent pass. The new file alone: 12 passed (12). Exit codes captured by redirecting to a file first, never after a pipe."
},
"gates": [
{ "name": "vitest apps/console (repo root)", "status": "pass", "detail": "Test Files 66 passed (66) · Tests 722 passed (722)" },
{ "name": "pnpm --filter @object-ui/console type-check", "status": "pass", "detail": "exit 0; script name echoed, not a zero-match no-op" },
{ "name": "pnpm --filter @object-ui/console lint", "status": "pass", "detail": "exit 0 · 201 problems (0 errors, 201 warnings), all pre-existing no-explicit-any; the two touched files contribute 0 errors and the new test file 0 warnings" },
{ "name": "check:control-bytes", "status": "pass", "detail": "OK (scanned 4673 tracked text file(s); skipped 85 binary)" },
{ "name": "check-changeset-presence", "status": "pass", "detail": "1 changeset declared for 2 source files of 1 released package" },
{ "name": "check-changeset-fixed", "status": "pass" },
{ "name": "check-changeset-no-major", "status": "pass" },
{ "name": "check:phantom-deps", "status": "pass", "detail": "Every in-scope import is declared by the package that publishes it (@object-ui/core is already a declared dep of apps/console)" },
{ "name": "check:lint-coverage", "status": "pass", "detail": "46/46 packages linted, 0 with outstanding errors" },
{ "name": "check:type-check-coverage", "status": "pass", "detail": "45/46 via type-check, 0 known-broken" },
{ "name": "check:eager-closure", "status": "broken-gauge", "detail": "exit 2 — 'No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a passing budget.' Needs a console vite build to write the report. Noted, not repaired, per dispatch. Considered on its merits: the one new import is evalFieldPredicate from @object-ui/core, already eager in this bundle (55 files under packages/app-shell/src import it, and App.tsx imports app-shell statically), so no new package enters the eager closure." },
{ "name": "check:doc-snippets", "status": "not-run", "detail": "the dispatch's other named broken gauge (exit 1 until the workspace is built); nothing in this diff touches docs or snippets, so it was not in the derived set" },
{ "name": "CI farm", "status": "in_progress", "detail": "Draft PR opened; per the standing clause the report is delivered at draft-PR time and CI convergence is the PM's read, not a wait this seat performs." }
],
"scope_fence_respected": {
"folded_renderer": false,
"note": "The triage fence was honoured literally. FormPage was NOT folded onto the plugin-form chain, neither renderer was deleted or deprecated, and nothing was 'unified'. The diff is confined to apps/console. On the fence's own invitation to say so if I became convinced the fold is right: I did not. Two independent reasons, both from reading the code rather than from deference. (1) The two renderers are not near-duplicates that a fold would collapse cheaply — FormPage owns a whole loader/submit surface the plugin-form chain has no concept of: anonymous /forms/:slug resolution, ?recordId=/?recordObject= create-vs-edit-vs-refuse (#4278/#4292), submitBehavior including the ruled relative-redirect (#4190/objectstack#7496), and the ExpandedViewItem unwrap (#2208). Each carries its own landed ruling and its own pins. (2) The predicate gap did not need the fold to close, and closing it does not make the fold harder — the shared piece is now the canonical ENGINE, which is the part that actually has to agree. What the fold would buy is one renderer instead of two, and that is a scope and sequencing question for the #5596 track, not a side effect of a bug fix. I did file what the fence explicitly did not cover — see out_of_scope_findings."
},
"open_questions": [],
"out_of_scope_findings": [
"filed as #5627: FormPage still drops the other three conditional-rule surfaces after this card — section-level visibleWhen/visibleOn (its local FormSectionSpec declares four keys and no visibility, and the render maps every section unconditionally at FormPage.tsx:1398, while app-shell's counterpart declares AND honours it via readVisibility), and the object-level field rules visibleWhen/readonlyWhen/requiredWhen (its local ObjectFieldDef admits eight keys and none of the ADR-0036 rules, while the sibling's fromObjectSchema copies all three). Filed unassigned, no labels, for PM triage. Deliberately NOT taken under the bounded-in-place-fix exemption: it fails the 'mechanical, shape already pinned by evidence' condition — it widens the honoured surface into disabled state and submit-blocking, and it raises two real semantics questions (whether a predicate-hidden SECTION should suppress its fields' values on submit, and whether requiredWhen here needs resolveFieldRuleState's serverOwnedValue carve-out given this route serves both create and edit)."
],
"notes": [
"Report shape: the dispatch asked for {files_changed, what_changed, evidence, changeset, tests, gates, scope_fence_respected, open_questions} while the standing dev contract requires {issue, status, branch, pr, premise_still_valid, summary, tests, open_questions, out_of_scope_findings}. Rather than choose, this report is the SUPERSET so either parser finds its keys. Flagging the conflict rather than silently picking a side.",
"Attribution footer: the dispatch specified the barehttps://claude.ai/codeform, but the standing byte discipline requires the session-URL form on PR bodies because the bare form is stripped on every edit. Measured here: the bare footer on issue #5627's body was stripped at CREATION, not just on edit. Both the PR body and that issue body now carry the session-URL form; comments keep the bare form, where it survives.",
"Sanitizer hit, caught and repaired: the first version of the PR body quoted evalFieldPredicate's real signature and GitHub silently ate every generic argument list — the angle-bracketed [string, unknown] after each Record — INSIDE a fenced code block, leaving a barerecord: Record. Caught by reading the body back, per AGENTS.md 'GitHub mutates body BYTES'. The PR body now spells the generic arguments out and says why it does. Writing this very note is what caught a second instance: the sentence describing the eaten token contained the token.",
"Angle brackets in this report are spelled out with square brackets (Record[string, unknown], trap RESTORE, field 'NAME') because GitHub eats short angle-bracket fragments from comment bodies. The real spellings are in the source and in the PR body, which says the same thing about itself."
]
}
Generated by Claude Code
Correction to the report comment above — not a second report (deliberately carries no report marker; the report is the comment immediately preceding this one, and it has been posted once and not rewritten).
Notes 3 and 4 of that report are wrong about the mechanism and are superseded by the lane ruling. I recorded that GitHub's body sanitizer had eaten the generic argument lists out of the PR description, and I contorted the prose to dodge it. It had not. The bytes reached GitHub intact; the MCP body reader sanitizes tag-shaped text on the way back to the agent. The write was never the problem — the read was.
Measured three ways after the ruling arrived:
- The report comment above reads back with every
"rendered as"and every->as->. Those quote characters obviously were not destroyed on write — the JSON would be meaningless — so the reader is demonstrably the mutator. - The
os-dev-reportmarker likewise does not appear in the read-back body. Per the ruling that is the expected appearance through that tool and is not evidence of anything, so the comment was not rewritten. No duplicate report was produced. - Fetching the rendered PR page directly shows
record: Record<string, unknown>present with both type arguments.
The PR description has been restored to the true, literal signature and the fabricated sanitizer paragraph removed — rebuilt from the original local source, never round-tripped through the sanitizing reader, so no quoted gate output was double-escaped. The duplicated attribution footer on that description is server-side and has been left alone per the standing ruling.
Recording it here rather than only in the return message because a confidently-worded false mechanism in a report is exactly the kind of thing that becomes an institutional lesson. The real lesson is the inverse of what I wrote: do not repair a body on the strength of a read-back through a sanitizing reader — confirm against the rendered page first.
Generated by Claude Code
- The report comment above reads back with every
- added a commit that references this issue
on Aug 23, 2026 - added 2 commits that reference this issue
on Aug 23, 2026 - added a commit that references this issue
on Sep 28, 2026
Found while implementing #5542 (converging the console's
FormFieldSpeconto theshared app-shell declaration). Filed unassigned for PM triage — outside that card's
declared file surface, which was the type declaration and its pin, not the renderer.
Mechanism
apps/console/src/components/FormPage.tsxis a second, independent form renderer:it has its own
buildSectionsand its own JSX, and it serves both the public/forms/:slugroute and the internal/forms/:nameroute. Verified on the merged refcad512fe1, it contains zero occurrences ofvisibleWhenorvisibleOn:The only visibility it honours is the static boolean, at
:1300:So a FormView field carrying a conditional predicate — legal, spec-strict metadata that
@objectstack/specnormalises tovisibleWhen(ADR-0089), and that the metadata-admindesigner both authors and honours — renders unconditionally on these two routes.
Fail-open and silent: an author who conditions a field on
record.priority == 'urgent'sees it always, with no diagnostic.
Why this is not a duplicate of #2212
#2212 recorded exactly this symptom and PR #2214 fixed it — in a different chain:
ModalForm→resolveFormViewLayout→packages/plugin-formsectionFields.ts→packages/componentsrenderers/form/form.tsx.FormPage.tsxis not on that chain atany point; it never calls
buildSectionFieldsand never renders throughSchemaRenderer type:'form'. The fix landed in one of the two renderers that read thiskey, and the fact that there were two was not visible from inside either.
That is the same reason #5542 exists one level down: one contract, several independent
implementations, each only ever checked against itself. #2212's own regression test
lives with the chain it fixed, so nothing in the suite can see this renderer's copy.
Severity note
User-reachable, unlike #5542 — this is a rendered-page behaviour on the public form
route, not a typing gap. But it is a fail-open, not a data-integrity defect, and the
correct fix direction is #2212's ruling verbatim: route the predicate through the
canonical engine (
evalFieldPredicate,packages/coreevaluator/fieldRules.ts),which already accepts
stringand thedialect/sourceobject arm. Whether that isworth doing here or whether
FormPage.tsxshould instead be folded onto the plugin-formchain is an architectural call, not mine at filing time — the second renderer is arguably
the real finding.
Boundary
conditional field phrasings). Form-view FormField.visibleOn (CEL) is never evaluated — conditional fields always render #2212, Conditional-visibility predicates (
visibleWhen/visibleOn) fail OPEN and silently — a broken predicate is indistinguishable from no predicate #4051, DrawerForm's no-sections field builder dropsvisibleWhen/readonlyWhen/requiredWhen(andgroup) — ModalForm's identical builder carries them #4755 and Renderer/actionvisible/disabledpredicates bypass the canonical CEL engine — home-grown JS evaluator diverges from server enforcement #2661 all match the class; allare closed, and none of them touches
apps/console/src/components/FormPage.tsx.it. [finding] A THIRD inline copy of the form-field authoring contract lives in apps/console FormPage.tsx — objectui#5040 converged only the app-shell two #5542 only made the key declarable in this file — the console's local type did
not admit
visibleWhenat all, so the gap was previously invisible from the type sidetoo.