Skip to content

Commit b9e5a97

Browse files
committed
feat(core): declare the 18 spec-owned action keys ActionDef absorbed silently
`ActionDef` ends with `[key: string]: any`, so it accepted any key of any type — a typo (`targt`) and a retired spec key (`execute`) both type-checked, then the runner silently bound no handler (the #2169 shape). Step 1 made that audible with a dev-mode warning; this is step 2, promoting the keys that warning identified as legitimate into real fields. - Promote 18 spec-owned keys to explicit optional fields, every type DERIVED from `@objectstack/spec`'s `ActionInput` rather than hand-copied. A hand-written duplicate of a spec shape is a second contract that drifts, which is the failure this issue is about. - Derive from `z.input`, not `z.infer`: `ActionSchema` is a `ZodPipe` whose transform narrows `visible` to the `{ dialect, source }` envelope alone. This runner reads authored rows unparsed, so it sees the input shape; `Action` would have rejected the raw-string predicate ActionEngine supports. - Delete three `as any` casts in `ActionEngine` (`visible`, `requiredPermissions`, `locations`) that existed only because the fields were undeclared. Package `as any` count drops 9 -> 3. - Mark `@deprecated` only the four dialect keys the runner itself proves are aliases: `actionType` -> `type`, `api`/`endpoint` -> `target`, `navigate` -> flat `target`/`openIn`. The rest are runner mechanics with no spec spelling. `shortcut` and `bulkEnabled` derive to `undefined` because spec 17 retired both as `retiredKey()` tombstones — so authoring either, already a hard parse rejection, is now a compile error too. Hand-copying would have quietly re-legitimized two dead keys. The index signature stays; removing it is step 3, and the inverted pin asserting it is still present remains the issue's completion check. Refs objectstack-ai/objectstack#4075 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
1 parent d3584c6 commit b9e5a97

5 files changed

Lines changed: 415 additions & 20 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@object-ui/core": minor
3+
---
4+
5+
Declare the 18 spec-owned action keys `ActionDef` had been absorbing silently.
6+
7+
`ActionDef` ends with `[key: string]: any`, so it accepted any key of any type —
8+
a typo (`targt`) and a retired spec key (`execute`) both type-checked, then the
9+
runner silently bound no handler (the #2169 "Mark Done does nothing" shape).
10+
Step 1 (objectstack#4075) made that audible with a dev-mode warning. This is
11+
step 2: the keys the warning identified as legitimate are now real fields.
12+
13+
- **18 keys promoted to explicit optional fields**`ai`, `aria`, `bodyExtra`,
14+
`bodyShape`, `bulkEnabled`, `component`, `icon`, `locations`, `mode`,
15+
`objectName`, `order`, `recordIdField`, `recordIdParam`, `requiredPermissions`,
16+
`requiresFeature`, `shortcut`, `variant`, `visible`. Every type is **derived**
17+
from `@objectstack/spec`'s `ActionInput` (`SpecActionInput['locations']`, …),
18+
never hand-copied: a hand-written duplicate of a spec shape is a second
19+
contract that drifts, which is the failure this issue is about. Wrong-typed
20+
values are now compile errors — `order: 'first'`, `variant: 'chartreuse'`,
21+
`locations: ['nope']` — where before they were absorbed silently.
22+
- **Derived from `z.input`, not `z.infer`.** `ActionSchema` is a `ZodPipe` whose
23+
transform narrows `visible` from `string | { dialect, source }` to the
24+
envelope alone. This runner consumes authored/stored rows, which are
25+
rehydrated unparsed, so it sees the input shape; deriving from the inferred
26+
`Action` would have rejected the raw-string predicate `ActionEngine`
27+
explicitly supports.
28+
- **Three `as any` casts deleted** in `ActionEngine``visible` and
29+
`requiredPermissions` at the location filter, `locations` at registration.
30+
They existed only because the fields were undeclared.
31+
- **Four objectui-dialect keys marked `@deprecated`** with the spec spelling to
32+
use instead — `actionType` (→ `type`), `api` and `endpoint` (→ `target`;
33+
`executeAPI` already resolves `api || endpoint || target`), and `navigate`
34+
(→ flat `target` / `openIn`). Only these four: the remaining dialect keys are
35+
runner mechanics (chaining, toasts, post-execution reload/close) with no spec
36+
counterpart, and pointing them at a spelling that does not exist would be
37+
worse than leaving them declared.
38+
39+
**Breaking edge, deliberate.** `shortcut` and `bulkEnabled` were retired by
40+
`@objectstack/spec` 17 as `retiredKey()` tombstones (`z.never()`), so authoring
41+
either is already a hard parse rejection. Deriving their types rather than
42+
hand-writing them turns that runtime rejection into a **compile error**: code
43+
that assigned `shortcut: 'ctrl+k'` to an `ActionDef` compiled before and does
44+
not now. Such metadata was already refused by the platform — this only moves the
45+
failure to where it can be fixed. A host may still pass either explicitly via
46+
`ActionEngine.registerAction(action, { shortcut, bulkEnabled })`; only authored
47+
metadata stopped carrying them. `bulkEnabled`'s replacement is the list view's
48+
`bulkActions` / `bulkActionDefs`; `shortcut` has none.
49+
50+
The index signature **stays** — removing it is step 3, and the inverted pin
51+
asserting it is still present remains the issue's own completion check.

packages/core/src/actions/ActionEngine.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,10 @@ export class ActionEngine {
156156
registerActions(actions: ActionDef[]): void {
157157
for (const action of actions) {
158158
this.registerAction(action, {
159-
locations: (action as any).locations,
159+
// No cast: `locations` is a declared `ActionDef` field as of
160+
// objectstack#4075 step 2. The `as any` it replaces existed only
161+
// because the key reached this reader through the index signature.
162+
locations: action.locations,
160163
});
161164
}
162165
}
@@ -210,21 +213,21 @@ export class ActionEngine {
210213
// systemPermissions is unknown (undefined): the server still 403s, and
211214
// hiding on missing data is a worse regression than showing a button
212215
// that errors clearly. Mirrors the App/nav requiredPermissions gate.
213-
const required = (ra.action as any).requiredPermissions as string[] | undefined;
216+
const required = ra.action.requiredPermissions;
214217
if (!Array.isArray(required) || required.length === 0) return true;
215218
const held = (this.runner.getContext() as any)?.user?.systemPermissions as string[] | undefined;
216219
if (!Array.isArray(held)) return true;
217220
return required.every((p) => held.includes(p));
218221
})
219222
.filter(ra => {
220-
const raw = (ra.action as any).visible;
223+
const raw = ra.action.visible;
221224
if (raw == null || raw === '' || raw === true) return true;
222225
if (raw === false) return false;
223226
let expr: string | boolean;
224227
if (typeof raw === 'string') {
225228
expr = `\${${raw}}`;
226-
} else if (typeof raw === 'object' && typeof (raw as any).source === 'string') {
227-
const src = (raw as any).source as string;
229+
} else if (typeof raw === 'object' && typeof raw.source === 'string') {
230+
const src = raw.source;
228231
if (!src) return true;
229232
expr = `\${${src}}`;
230233
} else {
@@ -246,7 +249,7 @@ export class ActionEngine {
246249
// user }` eval scope. Silently hiding it makes that bug invisible
247250
// (the #2183 hunt). Warn once per predicate so it is diagnosable
248251
// without spamming re-renders.
249-
warnHiddenPredicate((ra.action as any).name, raw, err);
252+
warnHiddenPredicate(ra.action.name, raw, err);
250253
return false;
251254
}
252255
})

packages/core/src/actions/ActionRunner.ts

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323

2424
import type { RunnableActionType } from '@object-ui/types';
25+
import type { ActionInput as SpecActionInput } from '@objectstack/spec/ui';
2526
import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
2627
import { globalUndoManager, type UndoableOperation } from './UndoManager';
2728
import { warnOnUnknownActionKeys } from './actionKeys';
@@ -87,7 +88,12 @@ export interface ActionDef {
8788
/** Action type identifier — a `RunnableActionType` (the spec's six plus the
8889
* `navigation` alias of `url`) or a custom type with a registered handler. */
8990
type?: string;
90-
/** Legacy action type field */
91+
/**
92+
* Legacy action type field.
93+
*
94+
* @deprecated objectui dialect — use the spec spelling `type`. Both are read,
95+
* `type` first (objectstack#4075 step 2).
96+
*/
9197
actionType?: string;
9298
/** Action name (from UIActionSchema) */
9399
name?: string;
@@ -101,13 +107,36 @@ export interface ActionDef {
101107
condition?: string;
102108
/** Disabled expression — if truthy, skip action */
103109
disabled?: string | boolean;
104-
/** API endpoint (string URL or complex config) */
110+
/**
111+
* API endpoint (string URL or complex config).
112+
*
113+
* @deprecated objectui dialect — the spec spells an `api` action's endpoint
114+
* `target` (with the verb in `method`). `executeAPI` resolves
115+
* `api || endpoint || target`, so `target` alone is sufficient. The
116+
* `ApiConfig` object form has no spec counterpart and is objectui-only
117+
* (objectstack#4075 step 2).
118+
*/
105119
api?: string | ApiConfig;
106-
/** API endpoint URL (spec v2.0.1 alias) */
120+
/**
121+
* API endpoint URL.
122+
*
123+
* @deprecated objectui dialect — this was the "spec v2.0.1 alias", but spec 17
124+
* spells it `target`. `executeAPI` resolves `api || endpoint || target`
125+
* (objectstack#4075 step 2).
126+
*/
107127
endpoint?: string;
108128
/** HTTP method */
109129
method?: string;
110-
/** Navigation target */
130+
/**
131+
* Navigation target — a nested envelope carrying the `navigation` alias's own
132+
* spelling (`to` / `external` / `newTab` / `replace`).
133+
*
134+
* @deprecated objectui dialect — the spec has no nested navigation envelope;
135+
* it spells a navigation action `{ type: 'url', target, openIn }`.
136+
* `executeNavigation` reads `navigate.to || navigate.target || navigate.redirect`,
137+
* falling back to the same keys on the action itself, so a flat `target` works
138+
* today (objectstack#4075 step 2).
139+
*/
111140
navigate?: any;
112141
/** onClick callback (legacy) */
113142
onClick?: () => void | Promise<void>;
@@ -190,6 +219,97 @@ export interface ActionDef {
190219
* must perform all auth/authz itself (e.g. the cloud `/sso-open` endpoint,
191220
* which re-runs every check the POST half would have done). */
192221
newTabUrl?: string;
222+
223+
// ── Spec-owned keys, promoted from the index signature ─────────────────────
224+
//
225+
// objectstack#4075 step 2. Step 1's inventory found 18 keys that
226+
// `@objectstack/spec`'s `ActionSchema` owns and that authored metadata
227+
// carries today, but that `ActionDef` never declared — so they reached
228+
// readers only through `[key: string]: any`, and the two `ActionEngine`
229+
// actually reads (`visible`, `locations`) had to be read through an `as any`
230+
// cast. Declaring them is what removes those casts.
231+
//
232+
// Every type below is DERIVED from the spec via `SpecActionInput[...]`, never
233+
// hand-copied: a hand-written duplicate of a spec shape is a second contract
234+
// that drifts silently, which is the failure this whole issue is about.
235+
//
236+
// Derived from `z.input` (`ActionInput`), not `z.infer` (`Action`), and the
237+
// difference is load-bearing rather than stylistic. `ActionSchema` is a
238+
// `ZodPipe` whose transforms narrow the authored shape — `visible` is authored
239+
// as `string | { dialect, source }` but INFERS to the object form alone. This
240+
// runner consumes authored/stored rows, which #3903 established are rehydrated
241+
// UNPARSED, so it sees the input shape. Deriving from `Action` would have
242+
// type-errored the raw-string predicate that `ActionEngine` explicitly
243+
// supports and that `ActionEngine.visibility.test.ts` pins.
244+
//
245+
// `shortcut` and `bulkEnabled` land here as `undefined`, not as a usable type
246+
// — that is correct and deliberate. Spec 17 retired both as `retiredKey()`
247+
// tombstones (`z.never()`), so authoring either is a hard parse rejection.
248+
// Deriving rather than hand-writing turns that rejection into a COMPILE error
249+
// for free; hand-copying would have re-legitimized two dead keys.
250+
251+
/** Where this action renders (`record_header`, `list_toolbar`, …). Read by
252+
* `ActionEngine.registerActions` to seed the location filter. */
253+
locations?: SpecActionInput['locations'];
254+
/**
255+
* Visibility predicate, evaluated against the runner's context.
256+
*
257+
* The `boolean` arm is objectui's own, deliberately wider than the spec:
258+
* `ActionSchema.visible` admits only a CEL string or a `{ dialect, source }`
259+
* envelope, while `ActionEngine.getActionsForLocation` also honours a literal
260+
* `visible: true` / `false` (pinned by `ActionEngine.visibility.test.ts`).
261+
* Declared rather than left to the index signature so the tolerance is
262+
* auditable instead of invisible — but it IS a dialect, and objectstack#4075
263+
* step 3 has to decide whether the spec adopts the boolean or objectui drops
264+
* it. Do not widen this further.
265+
*/
266+
visible?: SpecActionInput['visible'] | boolean;
267+
/** System permissions the caller must ALL hold for the action to be offered
268+
* (ADR-0066 D4 UI half — the server enforces the source of truth). */
269+
requiredPermissions?: SpecActionInput['requiredPermissions'];
270+
/** Icon name for the rendered control. */
271+
icon?: SpecActionInput['icon'];
272+
/** Visual emphasis of the rendered control (`primary`, `danger`, …). */
273+
variant?: SpecActionInput['variant'];
274+
/** Sort weight among sibling actions. */
275+
order?: SpecActionInput['order'];
276+
/** Which renderer surfaces the action (`action:button`, `action:menu`, …). */
277+
component?: SpecActionInput['component'];
278+
/** Object this action is declared against. */
279+
objectName?: SpecActionInput['objectName'];
280+
/** AI affordance metadata (tool exposure, prompts). */
281+
ai?: SpecActionInput['ai'];
282+
/** Accessibility overrides for the rendered control. */
283+
aria?: SpecActionInput['aria'];
284+
/** Extra properties merged into the request body alongside the collected params. */
285+
bodyExtra?: SpecActionInput['bodyExtra'];
286+
/** How collected params are shaped into the request body (`flat` or nested). */
287+
bodyShape?: SpecActionInput['bodyShape'];
288+
/** Execution mode of the action. */
289+
mode?: SpecActionInput['mode'];
290+
/** Field on the record supplying the record id sent to the endpoint. */
291+
recordIdField?: SpecActionInput['recordIdField'];
292+
/** Request-parameter name carrying the record id. */
293+
recordIdParam?: SpecActionInput['recordIdParam'];
294+
/** Auth/tenancy feature the action requires before it is offered. */
295+
requiresFeature?: SpecActionInput['requiresFeature'];
296+
/**
297+
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
298+
* authoring it is a hard parse rejection, so this resolves to `undefined` and
299+
* assigning a value is a compile error. A HOST may still pass a shortcut
300+
* explicitly via `ActionEngine.registerAction(action, { shortcut })`; it is
301+
* only no longer sourced from authored metadata. No replacement key.
302+
*/
303+
shortcut?: SpecActionInput['shortcut'];
304+
/**
305+
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
306+
* authoring it is a hard parse rejection, so this resolves to `undefined` and
307+
* assigning a value is a compile error. Use the list view's `bulkActions` /
308+
* `bulkActionDefs` instead; `ActionEngine.registerAction(action, { bulkEnabled })`
309+
* remains available to a HOST passing it explicitly.
310+
*/
311+
bulkEnabled?: SpecActionInput['bulkEnabled'];
312+
193313
/** Any additional properties */
194314
[key: string]: any;
195315
}

0 commit comments

Comments
 (0)