Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/actiondef-close-the-index-signature.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@object-ui/core": minor
---

Close `ActionDef` — delete the `[key: string]: any` index signature and converge `visible` / `disabled` on the spec's unified shape.

`ActionDef` accepted any key of any type, so a typo (`targt`) and a retired spec
key (`execute`) both type-checked and the runner then silently bound no handler
— the objectstack#2169 "Mark Done does nothing" shape. Step 1
(objectstack#4075) made that audible with a dev-mode warning; step 2 promoted
the 18 spec-owned keys to real fields. This is **step 3**, executing the
maintainer's 2026-08-06 ruling now that its upstream half shipped in
`@objectstack/spec` 17.0.0-rc.6 (objectstack#5970).

- **`visible` and `disabled` now have ONE shape, derived from the spec** —
`boolean | string(CEL) | { dialect, source }`. The ruling was "统一形状,spec
采纳": boolean is the degenerate literal verdict, the string is CEL shorthand,
the envelope is the full form. `visible` loses its hand-written `| boolean`
(the spec adopted that arm, so restating it locally would be a second
contract), and `disabled` gains the envelope arm it never had — it was
`string | boolean`, which is why the envelope the spec emits could only be
read through a cast.
- **The index signature is gone.** `tsc` now rejects an unknown or retired key
at any site that authors an action literal in code.
- **Five keys the deletion surfaced, promoted to real fields.** `to`,
`external`, `newTab`, `replace` — the `navigation` alias's own spelling, ruled
legitimate by step 1 and listed in `NAVIGATION_ALIAS_KEYS` ever since, but
declared only as data; and `description`, which every action renderer forwards
(`check:action-forward-parity` requires it) and the param-collection dialog
reads for its subtitle (objectui#4192). These were the only two `TS2353`s the
deletion produced across the whole workspace.
- **`ActionContext` keeps its index signature**, deliberately. It is a runtime
data bag whose keys are genuinely open; `ActionDef` is a declared metadata
contract. That asymmetry is the point, and it is now pinned in both
directions.

**Breaking edge, deliberate — same class as step 2's, one step further.** An
`ActionDef` literal carrying a key this interface does not declare is now a
compile error where it previously compiled and did nothing at runtime. That
includes the retired `execute` (rename it to `target`; `os migrate meta --from
16` rewrites it) and plain typos. Values that were only ever absorbed silently
are the ones that stop compiling, so the failure moves to where it can be fixed
rather than appearing as a button that does nothing.

**What did NOT retire with the index signature**, contrary to step 1's
expectation: the dev-mode `warnOnUnknownActionKeys` shim and `executeScript`'s
`execute` rename prescription both stay. `tsc` only ever sees actions authored
as TypeScript, while stored `sys_metadata` rows are rehydrated UNPARSED
(objectstack#3903) — which is the population `execute: 'markDone'` actually
lives in. The two mechanisms cover disjoint populations; retiring the runtime
half would have re-opened the gap it was written for.
18 changes: 18 additions & 0 deletions .changeset/declared-actions-bar-drop-predicate-casts.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
---
"@object-ui/app-shell": patch
---

`DeclaredActionsBar` reads `visible` / `disabled` off the typed action def instead of through `(action as any)`.

The `disabled` cast was the one the maintainer's 2026-08-06 ruling on
objectstack#4075 named: `ActionDef.disabled` was hand-written as
`string | boolean` and could not describe the `{ dialect, source }` envelope the
spec emits, so the bar had to reach around the type to evaluate it. With both
keys now derived from the spec's unified three-arm shape (`@object-ui/core`, step
3) there is nothing left to reach around. The adjacent `visible` casts go with
them for the same reason — step 2 declared `visible` and deleted `ActionEngine`'s
equivalent casts, but missed this file's.

No behaviour change: `toPredicateInput` and `hasDeclaredPredicate` both take
`unknown`, so the casts only ever affected whether the property access compiled,
never which verdict it produced.
24 changes: 15 additions & 9 deletions packages/app-shell/src/views/DeclaredActionsBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -150,16 +150,22 @@ const DeclaredActionButton: React.FC<{
// `visible` fails CLOSED on a throwing predicate — mirrors action:button and
// ActionEngine.getActionsForLocation: a guard that can't be evaluated hides
// the action rather than exposing one whose precondition is broken.
const isVisible = useCondition(toPredicateInput((action as any).visible), predicateRecord, {
const isVisible = useCondition(toPredicateInput(action.visible), predicateRecord, {
throwOnError: true,
label: `declared action "${action.name ?? action.label ?? 'action'}" (visible)`,
});
// Spec `disabled` (boolean | CEL — disabled when TRUE), evaluated against the
// same record context as `visible`. #1885 wired it in action-button only;
// this bar ignored it, so a spec-authored `disabled` guard on a declared
// action did nothing here. (No legacy `enabled` fallback: server-declared
// actions are spec-shaped and never carried the non-spec key.)
const isDisabledPred = useCondition(toPredicateInput((action as any).disabled), predicateRecord);
// Spec `disabled` — the same three arms as `visible` (`boolean | CEL string |
// { dialect, source }`, disabled when TRUE), evaluated against the same record
// context. #1885 wired it in action-button only; this bar ignored it, so a
// spec-authored `disabled` guard on a declared action did nothing here. (No
// legacy `enabled` fallback: server-declared actions are spec-shaped and never
// carried the non-spec key.)
//
// Read straight off the typed def since objectstack#4075 step 3: both keys are
// now derived from the spec's unified shape, so the `(action as any)` casts
// these two lines carried — which existed only because `ActionDef.disabled`
// could not describe the envelope arm — have nothing left to reach around.
const isDisabledPred = useCondition(toPredicateInput(action.disabled), predicateRecord);

const handleClick = useCallback(async () => {
if (loading) return;
Expand DownExpand Up@@ -239,7 +245,7 @@ const DeclaredActionButton: React.FC<{
// The verdict stays with the evaluation entry above: `toPredicateInput` passes
// a boolean through untouched and `useCondition` short-circuits it instead of
// calling the expression engine, so a declared `false` is `false`.
if (hasDeclaredVisibilityGate((action as any).visible) && !isVisible) return null;
if (hasDeclaredVisibilityGate(action.visible) && !isVisible) return null;

const iconName = typeof (action as any).icon === 'string' ? (action as any).icon as string : undefined;
// Map the spec's action `variant` enum (primary|secondary|danger|ghost|link)
Expand DownExpand Up@@ -277,7 +283,7 @@ const DeclaredActionButton: React.FC<{
// a permanently greyed-out Approve / Reject — the mirror image of
// objectui#3835 on the same surface, and equally impossible to tell from
// deliberate metadata by looking at it.
disabled={(hasDeclaredVisibilityGate((action as any).disabled) ? isDisabledPred : false) || loading}
disabled={(hasDeclaredVisibilityGate(action.disabled) ? isDisabledPred : false) || loading}
onClick={handleClick}
data-testid={`declared-action-${action.name}`}
>
Expand Down
Loading
Loading