Skip to content

Console FormPage (the standalone /forms and internal FormView renderer) never evaluates visibleWhen/visibleOn — objectui#2212 was fixed in the OTHER form renderer #5594

Description

@os-sales

Found while implementing #5542 (converging the console's FormFieldSpec onto the
shared 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.tsx is a second, independent form renderer:
it has its own buildSections and its own JSX, and it serves both the public
/forms/:slug route and the internal /forms/:name route. Verified on the merged ref
cad512fe1, it contains zero occurrences of visibleWhen or visibleOn:

grep -c 'visibleWhen\|visibleOn' apps/console/src/components/FormPage.tsx   ->   0

The only visibility it honours is the static boolean, at :1300:

{sec.fields.filter((f) => !f.hidden).map((f) => (

So a FormView field carrying a conditional predicate — legal, spec-strict metadata that
@objectstack/spec normalises to visibleWhen (ADR-0089), and that the metadata-admin
designer 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-form sectionFields.ts →
packages/components renderers/form/form.tsx. FormPage.tsx is not on that chain at
any point; it never calls buildSectionFields and never renders through
SchemaRenderer type:'form'. The fix landed in one of the two renderers that read this
key, 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/core evaluator/fieldRules.ts),
which already accepts string and the dialect/source object arm. Whether that is
worth doing here or whether FormPage.tsx should instead be folded onto the plugin-form
chain is an architectural call, not mine at filing time — the second renderer is arguably
the real finding.

Boundary

Activity

  1. huangyiirene commented on Aug 21, 2026

    @huangyiirene
    Collaborator

    Triage: lands in apps/console/src/components/FormPage.tsx — domain:ui, type Bug, promoted to pm:queue. User-reachable on the public /forms/:slug route: spec-legal visibleWhen/visibleOn metadata (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 — evalFieldPredicate in packages/core evaluator/fieldRules.ts, which already accepts both the string and dialect/source object 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

  2. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 21, 2026
  3. added theissue type on Aug 21, 2026
  4. self-assigned this
    on Aug 21, 2026
  5. os-sales commented on Aug 21, 2026

    @os-sales
    CollaboratorAuthor

    Claim — domain:ui execution 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

  6. added a commit that references this issue on Aug 21, 2026
    93187c3
  7. os-sales commented on Aug 21, 2026

    @os-sales
    CollaboratorAuthor

    {
    "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 quoted grep -c 'visibleWhen\\|visibleOn' FormPage.tsx -> 0 on 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 gains visibleWhen?: 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 resolves override.visibleWhen ?? override.visibleOn onto the row (carried, not evaluated: the verdict depends on live values, the rows are memoized on the loaded spec). New exported isFieldVisible(field, values, previous?) returns false for a static hidden: true and otherwise calls evalFieldPredicate(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), after pnpm --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 via export * from './evaluator/index.js' (packages/core/dist/index.d.ts:26). Spelling confirmed as evalFieldPredicate in 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.hidden filter 1; restore leg: isFieldVisible 4, old filter 0, git status --porcelain empty). The script carries trap 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=2 and pnpm --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 bare https://claude.ai/code form, 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 bare record: 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

  8. os-sales commented on Aug 21, 2026

    @os-sales
    CollaboratorAuthor

    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:

    1. 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.
    2. The os-dev-report marker 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.
    3. 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

  9. added 2 commits that reference this issue on Aug 23, 2026
    b63a9a3
    d8afbe5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions