Repository navigation
[P2] objectui @object-ui/types hand-declares five action vocabularies the spec owns — only ActionType is derived, and ActionParam has already drifted #4074
Description
Found while closing #3856 (objectui#2990). That issue predicted a TypeScript reader in objectui "should already fail to compile" once @objectstack/spec 17 removed action.execute — and it didn't. This is why, and the fallback was the smaller half of it.
Filed separately rather than widening objectui#2990, which deleted the dead alias.
The shape of it
@objectstack/spec/ui (packages/spec/src/ui/action.zod.ts) owns the whole action vocabulary and exports it under names objectui could import directly:
| spec export | line |
|---|---|
ACTION_LOCATIONS / ActionLocationSchema / ActionLocation |
290 / 300 / 301 |
ActionSchema (incl. the component enum) |
387 / 408 |
ActionParamSchema |
130 |
Action = z.infer<typeof ActionSchema> |
746 |
ActionParam = z.infer<typeof ActionParamSchema> |
747 |
ActionInput = z.input<typeof ActionSchema> |
748 |
objectui's packages/types/src/ui-action.ts imports exactly one of them — ActionType — and hand-declares the rest, while the doc comments claim spec canonicity:
@object-ui/types symbol |
what the comment says | what the code does |
|---|---|---|
ActionType |
derived (#2231/#2901) | ✅ z.infer<typeof SpecActionType> |
ActionLocation + ACTION_LOCATIONS + ActionLocationSchema |
"Single source of truth lives in @objectstack/spec/ui as ACTION_LOCATIONS + ActionLocationSchema + ActionLocation. Re-export here…" |
re-declares all three by hand |
ActionComponent |
"Canonical definition from @objectstack/spec/ui ActionSchema.component." |
hand-written 4-member union |
ActionParamFieldType |
"Aligned with the field types available in @objectstack/spec ActionParamSchema." |
hand-written 16-member union |
ActionParam |
— | hand-written 9-field interface |
ActionSchema |
— | hand-written interface |
ActionLocation and ActionComponent happen to match spec today, so those two are drift-prone rather than drifted. ActionParam is the one that already broke.
ActionParam has already drifted — two ways
1. type admits 16 of the spec's 49 field types. spec's ActionParamSchema.type is FieldType.optional(), and FieldType (packages/spec/src/data/field.zod.ts:13) has 49 members. objectui's ActionParamFieldType is a hand-picked 16 — a strict subset. So a spec-valid param typed lookup, multiselect, currency, percent, user, image, tags, json, code, markdown, richtext, radio, checkboxes, location, address, signature, … fails tsc against @object-ui/types.
2. It cannot express 13 of the 22 param capabilities objectui itself implements. @object-ui/core's ActionParamDef (packages/core/src/actions/ActionRunner.ts:277) declares 22 fields; the public ActionParam declares 9. Missing: visible, accept, maxSize, multiple, referenceTo, displayField, idField, descriptionField, titleFormat, lookupColumns, lookupFilters, lookupPageSize, dependsOn — every one of which ActionParamDialog renders through the shared field-widget renderer (objectui ADR-0059), as ActionParamDef's own comment states.
Note ActionParamDef.type is plain string. So the two objectui readers of one spec concept disagree with the spec and with each other: unbounded in core, 16 members in types, 49 in spec.
Also structural: spec makes name / label / type all optional, because field references an existing object field and inherits its label/type/validation/options. objectui's ActionParam makes all three required and omits field / objectOverride entirely — spec's primary way to declare a param is unrepresentable. And spec's option labels are I18nLabelSchema; objectui's are plain string, so a localized option label is a type error.
This is precisely the failure mode #2231/#2901 already fixed once, for the sibling symbol in the same file. That fix's own comment records it:
The previous union claimed to be the "canonical definition from @objectstack/spec" and was missing
form— so a host app typing against@object-ui/typesgot a type error ontype: 'form'even thoughActionRunner.executeFormimplements it.
Same file, same claim, same outcome — five symbols later.
Why this is the real finding of #3856
#3856 asked whether the compiler should already have rejected action.execute, "because if it still compiles, something in objectui is reading a looser type than the spec's." It still compiled, because no action type in objectui is bound to the spec's z.infer except ActionType. Removing a key from ActionSchema in spec 17 produced zero signal in objectui; objectui#2990 had to find the readers by grep and delete the declarations by hand.
Derivation is the only thing that makes the next spec removal audible. Until then every tombstone in spec (#3855, #3883) is a silent no-op against objectui's public types, and each one costs a manual grep-and-delete PR.
Suggested shape
- Re-export
ActionLocation/ACTION_LOCATIONS/ActionLocationSchemaand deriveActionComponentfromActionSchema.shape.component— mechanical, no behaviour change, and it makes the existing comments true. - Derive
ActionParamfromActionParamSchema. This is a breaking widening:name/label/typebecome optional andfield/objectOverrideappear, so host apps destructuringparam.nameneed a look. Worth pairing with narrowingActionParamDef.typefromstringtoFieldTypesocoreandtypesfinally agree. - Decide whether
ActionSchema(the interface) becomesActionInputor stays an objectui-side view. It carries genuinely objectui-only fields (openIn, and the renderer-facing docs), so a full swap may not be right — but the fields that do mirror spec should be derived rather than restated. - Add a parity guard, the way feat(kernel): kernel:bootstrapped anchor + fix everyone auto-bind ordering (#2926 follow-ups) #2989 did for the view-operator table: assert
ActionParamFieldType≡FieldTypeand that everyActionParamDeffield exists inActionParamSchema. The derivation is what fixes it; the guard is what keeps it fixed.
Step 1 is safe on its own. Steps 2–3 are breaking and want their own changeset.
Refs #3856 (objectui#2990), #2231 / #2901 / objectui#2944 (ea7f477 "retire the five forks that shadowed a @objectstack/spec vocabulary"), objectui#2974, objectui#2989, #3855, #3883, #3903.
Activity
os-zhuang commentedon Jul 30, 2026
Closing — all four steps have landed in objectui, and the guard that keeps them landed runs in CI.
ActionLocation/ACTION_LOCATIONS/ActionLocationSchema; deriveActionComponent886497167) —ui-action.ts:52-53are re-exports,:66isNonNullable<SpecAction['component']>ActionParamfromActionParamSchemab41f401e6)ActionSchema's fatecheck-spec-symbol-derivation.mjs'sALLOWwith the reason written downspec-derived-unions.test.ts, made enforcing by objectui#3009 (a045a32ff) — that package's tests were not type-checked before, so thesatisfiesannotations were decorationBoth drifts named in the title are gone.
ActionParamFieldTypeis nowSpecFieldTypeitself (49 members, not the hand-picked 16), withACTION_PARAM_FIELD_TYPES = SpecFieldTypeEnum.optionspinned by reference (expect(ACTION_PARAM_FIELD_TYPES).toBe(SpecFieldType.options)) — the objectui#3003 insight that a faithful copy passes every value comparison.ActionParamis now the authoring shape:name/label/typeoptional,field/objectOverridedeclared,labeltyped as the spec's ownI18nLabelimport, and all 22ActionParamDefcapabilities representable (the 13 that were missing —accept,maxSize,multiple, and the whole lookup-picker group — are declared).const _fieldBackedParam: ActionParam = { field: 'status' }compiles, which was the structural complaint.Two things worth recording rather than leaving implicit:
The "widening is breaking" framing resolved into a type split, not a widening. Step 2 anticipated host apps destructuring
param.nameneeding a look. What shipped instead separates authoring (@object-ui/typesActionParam, optional trio, field-reference form) from resolved (@object-ui/coreActionParamDef, required trio, post-resolveActionParams()). Conflating those is what made a spec-valid{ field: 'status' }a type error in the first place, so the split is the fix rather than a compromise on it.One suggestion from step 2 was deliberately not taken, and should not be re-derived as a finding.
ActionParamDef.typeis still plainstringin@object-ui/core, so the "unbounded incore, 49 in spec" half of the disagreement stands. Narrowing it was phrased here as "worth pairing with", and it belongs to the resolved shape, which now has its own reason to be looser than the authoring one. If it is still worth doing it is a small independent change, not this issue's remainder.The generalization of this failure class — a spec-named symbol must be an import, not a declaration — is #4115, where it is a CI gate (
check:spec-symbols, in objectui'stype-checkjob) with a 148-entry burn-down ledger.@object-ui/types:ActionSchemais the singleALLOWentry there, carrying step 3's decision.