diff --git a/.changeset/action-bar-member-declared-visible-gate-3823.md b/.changeset/action-bar-member-declared-visible-gate-3823.md deleted file mode 100644 index 18e5625900..0000000000 --- a/.changeset/action-bar-member-declared-visible-gate-3823.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@object-ui/components": patch ---- - -`action:bar` member actions declaring `visible: false` are now hidden instead of rendered - -`action:button` and `action:icon` carried the same truthiness gate objectui#3812 -removed from the member-action leaves — `if (schema.visible && !isVisible) -return null` — so `visible: false`, the most explicit way an author can say -"never show this", fell into the "no gate declared" branch and the action -rendered anyway. - -objectui#3812's triage judged the five component-level `schema.visible` gates a -dormant defensive layer, because `packages/react`'s `SchemaRenderer` evaluates -`newSchema.visible !== undefined` and hides the node before the component ever -mounts. Two of the five are not dormant, and this is the difference: - -`action:bar` does not route through `SchemaRenderer`. It resolves each member's -renderer from the `ComponentRegistry` itself and spreads the whole member action -onto that renderer's schema, so an author's `visible` on a member arrives as the -child's own `schema.visible` and lands on the child's gate. `action:bar` is also -the only gate on that path by design — its `filteredActions` deliberately -filters on `requiredPermissions` and `actionRendersAt` only, leaving `visible` to -the member renderer. The path is reachable end-to-end and is now pinned that way -(registry-mounted `action:bar`, member declaring `visible: false`), so the -reachability does not have to be argued again. - -Both gates now read the same named definition as the rest of the family, -`hasDeclaredVisibilityGate` (`!= null && !== ''`) — the invariant objectui#3492 -established for the selection bar and objectui#3758 applied to the row-action -surfaces. The evaluation entry is untouched and already short-circuits a boolean -rather than handing it to the CEL engine, which `actionPredicate.parity` pins for -both the engine and the renderer path. - -Behaviour change surface, deliberately narrow: only an `action:button` / -`action:icon` whose `visible` is the literal boolean `false` (or another falsy -non-empty value) changes — from rendered to hidden, which is what the -declaration asked for. `visible: true` still renders, `''` and an absent -`visible` are still no gate at all, and no expression-valued `visible` changes -verdict. `ActionSchema.visible` is `ExpressionInputSchema` with no boolean -member, so `objectstack build` cannot emit this shape; hand-written view JSON and -in-process callers constructing action defs can. - -The remaining three component-level gates (`action:group`, `action:menu`, -`action:bar`'s own) stay as they are — they only ever mount through -`SchemaRenderer`, which resolves `visible` first, and the overflow `action:menu` -that `action:bar` synthesizes carries no `visible` at all. diff --git a/.changeset/action-declared-disabled-gate-3842.md b/.changeset/action-declared-disabled-gate-3842.md deleted file mode 100644 index 57d743d7c7..0000000000 --- a/.changeset/action-declared-disabled-gate-3842.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@object-ui/app-shell": patch -"@object-ui/components": patch ---- - -An action declaring `disabled: ''` is no longer greyed out forever (objectui#3842) - -The "is a `disabled` gate declared?" test stopped at `!= null`, missing the -`!== ''` half of the invariant the `visible` family converged on -(`hasDeclaredVisibilityGate`, objectui#3492 / #3758 / #3812 / #3823 / #3835). So -`disabled: ''` counted as a declared gate, and the verdict went to the evaluation -entry — which reads an empty predicate as "no condition → `true`" -(`toPredicateInput('')` is `undefined`, `evaluateCondition(undefined)` is `true`). - -The direction is why this half is a defect and the `visible` half was not. On -`visible`, that `true` means SHOW, so an over-broad "declared" test and a -permissive empty predicate cancel out and `visible: ''` renders either way. On -`disabled`, the same `true` means DISABLE — the two mistakes compound, and an -empty predicate stopped meaning "no gate" and started meaning "permanently -greyed out". One empty predicate, opposite treatment under two keys. - -Two gates now ask the shared definition instead: - -- `@object-ui/app-shell`'s `DeclaredActionsBar` — the hot one. Its actions are - SERVER-declared (`objectDef.actions[]`) and its hosts are the approvals inbox's - record sections, so a `disabled: ''` arriving from metadata (an authoring form - left empty, a template that rendered to an empty string) produced an Approve / - Reject button nobody could click, indistinguishable from deliberate metadata. - objectui#3835 was this same surface failing the other way. -- `@object-ui/components`' `action:button` — verified to be the same shape before - it was changed (the issue inferred it from the identical spelling but did not - probe it): with `disabled: ''` the rendered button carried `disabled=""`. - -**Behaviour change surface, deliberately narrow.** Only `disabled: ''` changes — -from disabled to clickable, which is what "no predicate" asked for. `disabled: -true` still disables, `disabled: false` and an absent `disabled` still do not, and -no expression-valued `disabled` changes verdict. One consequence worth naming: on -`action:button`, an empty `disabled` now falls THROUGH to the legacy non-spec -`enabled` fallback instead of short-circuiting on the empty predicate, so an -action spelling both (`disabled: ''` + `enabled: true`) becomes clickable. - -The legacy `enabled` leg of `action:button` was routed through the same -definition for consistency, and that part is behaviour-preserving by derivation -rather than a fix: the leg is negated (`disabled = !isEnabled`), so an empty -predicate's `true` already arrived as "not disabled" — the same verdict "no gate" -produces. All four shapes are identical under either test; the derivation table -and the reason no test can distinguish them are written down next to the pins. - -`hasDeclaredVisibilityGate` keeps its historic name at both call sites (the -objectui#3842 dispatch ruling): the predicate is key-neutral, and one -implementation behind two names is how a repo grows dialects. Each call site says -so in a comment. diff --git a/.changeset/action-member-declared-disabled-gate-3849.md b/.changeset/action-member-declared-disabled-gate-3849.md deleted file mode 100644 index f66b66875c..0000000000 --- a/.changeset/action-member-declared-disabled-gate-3849.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@object-ui/components": patch -"@object-ui/plugin-detail": patch ---- - -`disabled: ''` no longer greys out the remaining five action surfaces (objectui#3849) - -objectui#3842 / PR #3851 fixed the "is a `disabled` gate DECLARED?" test on -`action:button` and app-shell's `DeclaredActionsBar`. Five same-shaped sites were -outside that PR's scope and stayed on `!= null`, so within one component the -`visible` gate asked `hasDeclaredVisibilityGate` while the `disabled` gate on the -next line asked `!= null` — two spellings of one question: - -- `@object-ui/components` — `action:icon`, `action:group`'s inline button - (`InlineActionButton`) and dropdown item (`DropdownActionItem`), and - `action:menu`'s item (`ActionMenuItem`). -- `@object-ui/plugin-detail` — `record:quick_actions`' `QuickActionButton`. - -Why the missing `!== ''` half is a defect on this key and not on `visible`: -`toPredicateInput('')` is `undefined` and `evaluateCondition(undefined)` is -`true`. On `visible` that `true` means SHOW, so an over-broad "declared" test and -a permissive empty predicate cancel out. On `disabled` it means DISABLE, so they -compound — `disabled: ''` (an empty predicate: nothing declared) rendered a -permanently greyed-out control, with nothing the author could write to un-grey -it. Unlike #3842's approvals inbox, these five are the general action face -(toolbars, dropdowns, record quick actions), so the reach is wider even though no -single high-value host owns them. - -**Behaviour change surface, deliberately narrow.** Only `disabled: ''` changes — -from disabled to clickable, which is what "no predicate" asked for. `disabled: -true` still disables, `disabled: false` and an absent `disabled` still do not, and -no expression-valued `disabled` changes verdict. On the four sites that also carry -the legacy non-spec `enabled` fallback, one consequence follows: an empty -`disabled` now falls THROUGH to that leg instead of short-circuiting on the empty -predicate, so an action spelling both (`disabled: ''` + `enabled: true`) becomes -clickable. `record:quick_actions` has no `enabled` leg, so its chain is the single -gate. - -Routing those legacy `enabled` legs through the same definition is -behaviour-preserving by derivation rather than a fix: the leg is negated -(`disabled = !isEnabled`), so an empty predicate's `true` already arrived as "not -disabled" — the verdict "no gate declared" produces. #3842's four-shape derivation -table is reproduced next to the new pins, together with the statement that no -`enabled` case can go red by reverting that leg. - -`hasDeclaredVisibilityGate` keeps its historic name (the objectui#3842 ruling): the -predicate is key-neutral, and one implementation behind two names is how a repo -grows dialects. The three `@object-ui/components` sites import it relatively; -`record:quick_actions` takes it from the package barrel, the cross-package route -objectui#3835 opened. Every call site says so in a comment. diff --git a/.changeset/action-member-declared-visible-gate-3812.md b/.changeset/action-member-declared-visible-gate-3812.md deleted file mode 100644 index e0de1e891c..0000000000 --- a/.changeset/action-member-declared-visible-gate-3812.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@object-ui/components": patch ---- - -Action-face member actions declaring `visible: false` are now hidden instead of rendered - -The three member-action gates on the action face asked truthiness — -`if (action.visible && !isVisible) return null` — so `visible: false`, the most -explicit way an author can say "never show this", fell into the "no gate -declared" branch and the action rendered anyway: - -- `action:group` in `display: 'inline'` mode (`InlineActionButton`); -- `action:group` in `display: 'dropdown'` mode (`DropdownActionItem`); -- `action:menu`'s items (`ActionMenuItem`). - -These leaves `.map()` the component's own `actions` array, so neither -`SchemaRenderer`'s node-level `visible` handling nor -`ActionEngine.getActionsForLocation` (whose boolean `visible` was always -correct) is in the path — the truthy gate was the only gate. - -All three now read one named definition, `hasDeclaredVisibilityGate` -(`!= null && !== ''`), and let the declaration itself decide. This is not a new -decision: objectui#3492 established the invariant for the selection bar, whose -`hasVisibilityGate` spells out why truthiness cannot answer the question, and -objectui#3758 applied it to both row-action surfaces. The evaluation entry is -untouched and already short-circuits a boolean rather than handing it to the CEL -engine, which `actionPredicate.parity` pins for both the engine and the renderer -path. - -Behaviour change surface, deliberately narrow: only a member action whose -`visible` is the literal boolean `false` (or another falsy non-empty value) -changes — from rendered to hidden, which is what the declaration asked for. -`visible: true` still renders, `''` and an absent `visible` are still no gate at -all, and no expression-valued `visible` changes verdict. -`ActionSchema.visible` is `ExpressionInputSchema` with no boolean member, so -`objectstack build` cannot emit this shape; hand-written view JSON and -in-process callers constructing action defs can. diff --git a/.changeset/action-param-datetime-zoned-instant-os5061.md b/.changeset/action-param-datetime-zoned-instant-os5061.md deleted file mode 100644 index f916e31a90..0000000000 --- a/.changeset/action-param-datetime-zoned-instant-os5061.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@object-ui/app-shell": patch ---- - -`datetime` action params are usable in the Console for the first time — the dialog now POSTs the zoned ISO instant the platform requires instead of a shape the validator rejects - -An action declaring a `type: 'datetime'` param was unusable from the UI: **no -value a user could pick could pass validation**. The dialog rendered the param -as `datetime-local` (which is zone-less by nature) and then serialized on -submit back to that control's own naive wall clock, e.g. `2026-08-10T15:00`. -Since 17.0 the dispatcher validates a params bag against the action's -declaration before the handler runs (ADR-0104 D2, `validateActionParams` → -`InstantValueSchema`), and that contract is an ISO-8601 instant with an -explicit zone. Every submission earned: - -``` -HTTP 400 VALIDATION_ERROR -Action param "start" (datetime): expected an ISO-8601 instant with explicit -zone (e.g. 2026-03-15T14:30:00.000Z) -``` - -The renderer and the validator wanted disjoint shapes, and an app author had no -seam between them — declaring a `datetime` param doomed the action in the UI, -whatever the app. Found in a hotcrm dogfood run (objectstack#5061), reproduced -from two separate entry points (list-view row menu and record header). - -The fix is a removal, not a conversion. `DateTimeField` has been ISO-canonical -on both sides since objectui#3127/#3565 — it takes the record's ISO instant in -and hands an ISO instant back out, seconds, milliseconds and zone included — so -the widget's own value already satisfies the contract. #3565 added the -back-conversion to keep the wire shape byte-identical while it fixed a display -bug, and named the follow-up in its own commit message: moving action params -onto ISO is a contract change of its own. This is that change. -`serializeParamValues` now passes `datetime` values through untouched, which -makes it idempotent for a value that already carries a zone (a `+08:00` offset -survives byte-for-byte rather than being re-derived and re-cut to the minute) -and leaves an empty or unfilled param alone. - -Deliberately still rejected: an authored `defaultValue` written as a zone-less -wall clock. That value is ambiguous metadata — whose zone? — and coercing it in -the renderer would make it "work" in the UI while the identical literal kept -400ing from REST and MCP, which is the worst split to debug. It stays loud -until the spec validates a param default against the param's own value -contract, filed as objectstack#6970 (the same hole lets a `number` param -default to `'abc'`, so it is not datetime-specific). - -The render proof that pinned the old shape was replaced rather than re-spelled: -it asserted `2026-07-20T14:30` and was green while the feature was 100% broken, -because the shape it pinned is the one shape nothing accepts. It now drives the -real widget and asks the real `validateActionParams` — the exact function that -produced the 400 — whether the resolved bag is acceptable. The datetime -assertions are written to hold in every timezone (verified under -`Asia/Shanghai`, `UTC` and `America/Los_Angeles`), since a zone-shaped test that -only holds in UTC goes green on CI while the defect is live for every user east -or west of it. diff --git a/.changeset/action-record-predicate-root-4075.md b/.changeset/action-record-predicate-root-4075.md deleted file mode 100644 index a84eeef146..0000000000 --- a/.changeset/action-record-predicate-root-4075.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@object-ui/components": patch -"@object-ui/react": patch ---- - -Action-face predicates written against the canonical `record.` root now evaluate - -`action:button`, `action:icon`, `action:menu` and `action:group` gated their -actions on `useCondition(pred, context)`, which evaluates on -`new ExpressionEvaluator({ ...scope, ...context })` — and the context each of -them passed was the row spread flat, or nothing at all. Only the shorthand -spelling resolved: - -| predicate | verdict, before | -|---|---| -| `status == "pending"` | evaluates (`action:button` only) | -| `record.status == "pending"` | throws `record is not defined` | -| `data.status == "pending"` | throws `data is not defined` | - -`record.` is not a mistaken spelling — it is the canonical one. It is what -`ExpressionEvaluator`'s CEL path binds (`bag.record` as the record namespace), -what `evalRowPredicate` binds on the record header, list rows, the row kebab -and conditional formatting (`record.status` / bare `status` / `data.status`), -and what the server enforces with. A `visible` that fails CLOSED turns the throw -into "hidden", so a correctly-authored predicate deleted its own button — -indistinguishable from the gate having said no. On the fail-soft legs the same -throw lands the other way: `disabled` greyed a control out for everyone. - -Live rather than theoretical: every declared action on framework's -`sys_approval_request` gates on `record.viewer.*`, so the whole server-declared -approval decision set was invisible wherever the declared-action bar rendered -until objectui#4077 fixed that bar. These four generic renderers carried the -same binding. - -What changed: - -- all four bind the row the three canonical ways, through one named helper - (`usePredicateRecordContext`, exported from `@object-ui/react` beside - `useCondition`), so the action face and the row surfaces answer an author's - `visible:` the same way; -- `action:icon` reads the row at all. It evaluated against an empty bag, so not - even the bare-field shorthand resolved — and its `data` prop was landing in - the props spread onto the DOM button; -- `action:menu`'s items and `action:group`'s two leaves receive the row from - their host, which they previously never got; -- `action:bar` forwards the row into the overflow menu it builds, not just to - its inline members. An action's predicate had been answering a different - question purely because it spilled past `maxVisible` — which on mobile - defaults to 1, making the verdict a function of the viewport. - -Deliberately unchanged: the evaluation entry and each site's error policy. A -predicate that genuinely faults still fails closed on `action:button` / -`action:menu` `visible` and still fails soft on the other legs, exactly as -before; `toPredicateInput`, `hasDeclaredVisibilityGate` and the empty-predicate -rules keep their pinned semantics. Binding the row is a separate question from -what to do when the predicate faults. - -A surface with no row of its own binds nothing rather than an empty record, so -a host that supplies the row through the ambient predicate scope is not blanked -out; a row passed explicitly still wins over the scope. diff --git a/.changeset/action-runner-declared-condition-gate-3872.md b/.changeset/action-runner-declared-condition-gate-3872.md deleted file mode 100644 index 74393aa77f..0000000000 --- a/.changeset/action-runner-declared-condition-gate-3872.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@object-ui/core": patch ---- - -`condition: false` now actually prevents the action from executing (objectui#3872) - -`ActionRunner.execute` — the engine's public execution entry, shared by every -action surface — gated conditional execution on `if (action.condition)`, i.e. on -the raw value's TRUTHINESS. Truthiness cannot answer the question the gate needs -answered ("did the author declare a condition?"), and on this key it answered in -the over-permissive direction: `condition: false` fell on `if (false)`, so the -whole block was skipped, `evaluateCondition` was never consulted, and the action -executed. Measured with a call-counting handler: - -- `condition: false` — handler ran: **true**, result `{ success: true }` -- `condition: { dialect: 'cel', source: 'false' }` — handler ran: false, result `{ success: false, error: 'Action condition not met' }` - -Two spellings of the same statement, opposite outcomes. `false` is the most -explicit "never execute this" metadata can carry — and what a template that -switches an action off emits — so the direction matters: the action really ran, -possibly writing. This is the over-permission half of the objectui#3492 family -(the `disabled` gate one line below is objectui#3848, whose defect pointed the -other way). - -The gate now asks whether a `condition` gate is DECLARED before evaluating. -"Nothing to evaluate" is read from core's single predicate normalizer -(`toPredicateInput`, which maps `''`, `null`, an empty-`source` envelope and -non-predicate values to `undefined`), plus the whitespace-only string, which the -normalizer wraps rather than collapses — objectui#3850's ruling on the scope of -"empty predicate", the same one the `disabled` gate already applies. Once the -door asks the right question the verdict needs no boolean branch of its own: -`evaluateCondition` returns a boolean argument verbatim. - -**Behaviour change surface, deliberately one-directional and one row wide.** -Exactly one shape changes verdict — a declared boolean `false`, from executing to -refused (`{ success: false, error: 'Action condition not met' }`, the message the -key already used). Everything else is byte-identical: `condition: true`, an -absent `condition`, and truthy expressions/envelopes still execute; falsy -expressions, falsy CEL envelopes and falsy `${…}` templates are still refused, -as before; the three empty predicates (`''`, whitespace-only, an empty-`source` -envelope) still execute, now because nothing was declared rather than because -`if ('')` happened to be falsy; and non-predicate junk (`0`, `{}`) still -executes — a value that is not a predicate must not decide an action's fate, -which is the fail-open posture this module already committed to for `disabled`. -So this change can only start refusing execution, never start allowing it — the -mirror image of objectui#3848's fix. - -`ActionDef.condition` is widened to `string | boolean` to match what the gate now -honours (and the `disabled` key beside it). This is not a lenient consumer -alias: the boolean was always accepted at runtime through the interface's index -signature, it was simply ignored. - -The value handed to the evaluator is deliberately left RAW rather than normalized -first, for the same reason as objectui#3848 with the sign flipped: -`toPredicateInput` wraps unconditionally, so an already-templated `'${x}'` -becomes `'${${x}}'`, fails to parse, returns verbatim and coerces to a constant -`true` — on `disabled` that blocks everything, on `condition` it would EXECUTE -everything. That normalizer defect is objectui#3871; a tripwire next to the new -pins goes red the day it is fixed. diff --git a/.changeset/action-runner-declared-disabled-gate-3848.md b/.changeset/action-runner-declared-disabled-gate-3848.md deleted file mode 100644 index 3690491dd6..0000000000 --- a/.changeset/action-runner-declared-disabled-gate-3848.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@object-ui/core": patch ---- - -An empty `disabled` predicate no longer refuses to run the action (objectui#3848) - -`ActionRunner.execute` — the engine's public execution entry, shared by every -action surface — gated on `action.disabled != null && action.disabled !== false` -and handed the value straight to `evaluateCondition`. That function documents one -default for "there is no condition here": return `true`, meaning -*visible/enabled*. On `disabled`, `true` means BLOCKED. So every empty predicate -was read as "disabled": the handler was never invoked and the caller got -`{ success: false, error: 'Action is disabled' }` — a state the metadata never -declared. Measured with a call-counting handler: - -- `disabled: ''` — handler ran: false -- `disabled: ' '` (whitespace only) — handler ran: false -- `disabled: { dialect: 'cel', source: '' }` (the empty envelope `objectstack build` can emit) — handler ran: false - -After objectui#3842 / objectui#3849 fixed the renderer halves, this was live -user-visible behaviour: the button became clickable and clicking it returned -`Action is disabled` — the renderer and the execution entry disagreeing about one -predicate value, the shape objectui#3314 already paid for once. - -The gate now asks whether a `disabled` gate is DECLARED — whether there is a -condition to reach a verdict on — before evaluating. "Nothing to evaluate" is -read from core's single predicate normalizer (`toPredicateInput`, which maps -`''`, `null`, an empty-`source` envelope and non-predicate values to -`undefined`), plus the whitespace-only string, which the normalizer wraps rather -than collapses and which `evaluateCondition` itself calls "no condition" -(`evalRowPredicate` applies the same blank-source rule). - -**Behaviour change surface, deliberately one-directional.** Only values with -nothing to evaluate change, and only from blocked to allowed: `''`, -whitespace-only, an empty-`source` envelope, and non-predicate junk (`0`, `{}`, -which previously coerced to "disabled"). `disabled: true`, a truthy expression -and a truthy CEL envelope still block; `disabled: false` and an absent `disabled` -still run; no expression- or envelope-valued predicate changes verdict. The -existing `catch { isDisabled = false }` fail-open posture is untouched, and this -change can only stop blocking things, never start. - -The value handed to the evaluator is deliberately left RAW rather than normalized -first. `evaluateCondition(toPredicateInput(x))` is not interchangeable with -`evaluateCondition(x)` for a string that is already a `${…}` template: -`toPredicateInput` assumes a bare expression and wraps unconditionally, so -`'${x}'` becomes `'${${x}}'`, fails to parse, returns verbatim, and coerces to a -constant `true` — a template-spelled predicate evaluated that way is ALWAYS -"disabled", whatever it says. That normalizer defect is filed as objectui#3871 -(it is live at the action renderers and `ActionEngine`, while `SchemaRenderer` and -`page:header` evaluate the raw value and pin the correct verdict); a tripwire next -to the new pins goes red the day it is fixed. Two rows therefore still differ -between the execution and renderer paths, each recorded with its owning issue: -the empty envelope (objectui#3850 owns the renderer half's scope ruling) and the -`${…}` spelling (objectui#3871). diff --git a/.changeset/actionparamdialog-boolean-host-id-3962.md b/.changeset/actionparamdialog-boolean-host-id-3962.md deleted file mode 100644 index 317015767d..0000000000 --- a/.changeset/actionparamdialog-boolean-host-id-3962.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@object-ui/app-shell": patch ---- - -`ActionParamDialog` boolean params: the dialog now owns the control id, so the checkbox is named once instead of twice - -The boolean branch rendered `