feat(ext-api): A16 closure — D-widget-serialization-simplification (wave-9)

Codify A15 (Widget Declarativity) + A16 (Unified Serialization Target) by
collapsing widget-level serialization to its irreducible core.

Per D-widget-serialization-simplification (wave-9, 2026-05-20) — comment out
in src/extension-api/widget.ts (A14 pattern):

- WidgetHandle.setSerializeEnabled(enabled) / .isSerializeEnabled()
- WidgetBeforeSerializeEvent.context: 'workflow'|'prompt'|'clone'|'subgraph-promote'
- WidgetBeforeSerializeEvent.skip()
- WidgetPropertyChangeEvent (vacuous union after 'serialize' removed)
- WidgetHandle.on('propertyChange', handler) overload
- WidgetOptions.serialize?: boolean key

Per A16, the sole extension-author interface to serialization is
`widget.on('beforeSerialize', handler)`. The framework writes one payload
to every transport (workflow JSON, API prompt, clone, subgraph-promote);
authors do not branch on transport and cannot disable serialization. Per
A15, runtime widget addition was the underlying reason for the disable
matrix — declarativity removes the cause, A16 removes the escape hatch.

Removed framework-side adapters in src/services/extension-api-service.ts:
isSerializeEnabled / setSerializeEnabled blocks (lines 302-315) and the
propertyChange branch of the event dispatcher (line 362). Dropped
WidgetComponentSerialize import (now unused).

Removed WidgetPropertyChangeEvent from src/extension-api/index.ts barrel.

Updated JSDoc: stripped 4-context narrative + skip() examples from
WidgetBeforeSerializeEvent, dropped widget.options.serialize references
from the options accessor, dropped e.context === 'prompt' from the
serializeValue accessor example.

node.on('beforeSerialize') (separate surface) remains `@deprecated` +
runtime-warned per ADR-0010 — NOT banned by this commit (per user
direction 2026-05-20).

Blast radius: 0 v2 ext call sites for any removed surface (verified via
rg sweep across restack-ext-v2/src and foundation extensions/core).
v1 path untouched per D6 parallel-paths migration.

Refs:
- decisions/D-widget-serialization-simplification.md (ACCEPTED 2026-05-20)
- AXIOMS.md §A15 (Widget Declarativity), §A16 (Unified Serialization Target)
- AXIOMS.md §A14 (7 new rows in COMMENTED OUT table)
- D14-serialization-convergence.md (predecessor — promised this end-state)
- D-no-node-widget-access.md (wave-8, structural sibling)
- design-review-12142.md row #11

Verified: scripts/check-axiom-compliance.sh  all checks pass
(A1, A2, A3, A4, A6, A15, A16 — strict-fail on re-introduction)
This commit is contained in:
Connor Byrne
2026-05-20 13:41:40 -07:00
parent 446d0a216e
commit e56187adf3
3 changed files with 109 additions and 133 deletions

View File

@@ -120,7 +120,7 @@ export type {
WidgetOptions,
WidgetValueChangeEvent,
WidgetOptionChangeEvent,
WidgetPropertyChangeEvent,
// WidgetPropertyChangeEvent removed per A16 (D-widget-serialization-simplification, wave-9)
WidgetBeforeSerializeEvent,
WidgetBeforeQueueEvent,
// Mount-lifecycle surface per D-widget-converge / Axiom A12 ────────────

View File

@@ -74,85 +74,66 @@ export interface WidgetOptionChangeEvent {
readonly newValue: unknown
}
/**
* Payload for `widget.on('propertyChange', handler)`.
*
* Fires when a first-class every-widget property is mutated — specifically
* `hidden`, `disabled`, and `serialize`. Does NOT fire for `value` changes
* (use `valueChange`) or for options-bag mutations (use `optionChange`).
*
* @stability experimental
* @example
* ```ts
* widget.on('propertyChange', (e) => {
* if (e.property === 'hidden') updateLayout(e.newValue as boolean)
* })
* ```
*/
export interface WidgetPropertyChangeEvent {
/**
* Which first-class property changed.
* - `'serialize'` — serialization opt-in/out via `setSerializeEnabled()`
*
* PHASE_A_EXCLUDED per AXIOMS.md A14:
* - `'hidden'` — visibility toggled via `setHidden()` (deferred)
* - `'disabled'` — enabled/disabled via `setDisabled()` (deferred)
*/
readonly property: 'serialize' // Phase A: 'hidden' | 'disabled' deferred
/** Value before the change. */
readonly oldValue: boolean
/** Value after the change. */
readonly newValue: boolean
}
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
// `WidgetPropertyChangeEvent` is vacuous after `'serialize'` was removed from
// the property union (A16: authors cannot disable serialization). The other
// historical members ('hidden', 'disabled') were already A14-deferred.
// Restoration requires a new first-class property to surface that satisfies
// A1A16 and an axiom amendment.
//
// export interface WidgetPropertyChangeEvent {
// readonly property: 'serialize'
// readonly oldValue: boolean
// readonly newValue: boolean
// }
/**
* Payload for `widget.on('beforeSerialize', handler)`.
*
* This is the **only async-allowed event** in the API.
* Replaces `widget.serializeValue`, `widget.options.serialize = false`, and
* the v1 `widget.serializeValue = (workflowNode, widgetIndex) => ...` pattern.
* This is the **only async-allowed event** in the API and, per AXIOMS.md
* §A16 (Unified Serialization Target), the **sole** extension-author
* interface to serialization. Replaces every v1 serialization hook:
* `widget.serializeValue = fn`, `widget.options.serialize = false`,
* `nodeType.prototype.serialize`.
*
* Call `event.setSerializedValue(v)` to override what is written to
* `widgets_values[i]` and the API prompt. Call `event.skip()` to exclude this
* widget from the prompt entirely. Do not call either to pass through the
* widget's current `getValue()` unchanged.
* The hook fires **once per serialization**. The framework writes the
* resulting payload to every transport (workflow JSON `widgets_values[i]`,
* API prompt, clone target, subgraph promotion). Extensions do not see and
* cannot branch on the transport — that is a framework concern (A16).
*
* Call `event.setSerializedValue(v)` to override what is written. Do not
* call it to pass through the widget's current `getValue()` unchanged.
*
* Per A16 there is no `skip()` and no `context` discriminator. Per A15
* (Widget Declarativity) there is no way to exclude a widget from
* serialization — if a widget should not contribute to the payload, it
* should not be a widget (use a boxed widget, a non-widget UI primitive,
* or a schema input).
*
* @typeParam T - The widget's value type.
* @example
* ```ts
* // Dynamic prompts: replace value at serialize time
* widget.on('beforeSerialize', (e) => {
* if (e.context === 'prompt') {
* e.setSerializedValue(processDynamicPrompt(widget.getValue()))
* }
* e.setSerializedValue(processDynamicPrompt(widget.getValue()))
* })
*
* // Preview widget: exclude from prompt
* widget.on('beforeSerialize', (e) => {
* if (e.context === 'prompt') e.skip()
* })
*
* // Async: webcam capture — materialize frame before prompt builds
* // Async: webcam capture — materialize frame before serialization
* widget.on('beforeSerialize', async (e) => {
* if (e.context === 'prompt') {
* const frame = await captureFrame()
* e.setSerializedValue(frame)
* }
* const frame = await captureFrame()
* e.setSerializedValue(frame)
* })
* ```
*/
export interface WidgetBeforeSerializeEvent<T = WidgetValue> {
/**
* Which serialization path triggered this handler.
*
* - `'workflow'` — user is saving the workflow to disk (full round-trip).
* - `'prompt'` — user is queueing a run (only prompt-relevant data sent to backend).
* - `'clone'` — a copy/paste is happening; the framework already populated the
* cloned entity's widget value from the source. Override only if the clone should
* differ from the source.
* - `'subgraph-promote'` — the widget is being promoted to a subgraph IO slot.
*/
readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
// The 4-way transport discriminator inverted the direction of knowledge
// flow — framework owns transport, extensions own value. Workflow JSON
// and API prompt converge to a single serialized payload; clone and
// subgraph-promote are framework concerns. Restoration requires an
// axiom amendment to A16.
//
// readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
/**
* The widget's current value at the time of serialization (before any override).
@@ -161,20 +142,21 @@ export interface WidgetBeforeSerializeEvent<T = WidgetValue> {
readonly value: T
/**
* Override the serialized value. The provided value is written to
* `widgets_values[i]` (and to the API prompt for `context='prompt'`).
* Calling this multiple times keeps the last call's value.
* Override the serialized value. The provided value is written to every
* transport (workflow JSON `widgets_values[i]`, API prompt, clone target,
* subgraph promotion). Calling this multiple times keeps the last call's
* value.
*
* @param v - The value to serialize. Must be JSON-serializable.
*/
setSerializedValue(v: unknown): void
/**
* Exclude this widget from the API prompt entirely.
* Only meaningful for `context='prompt'`; no-ops on other contexts.
* Replaces `widget.options.serialize = false` and `() => undefined` patterns.
*/
skip(): void
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
// `skip()` IS a per-call disable — authors cannot disable serialization
// (A16). If a widget should not contribute to the payload, it should not
// be a widget (A15). Restoration requires axiom amendments to A15 + A16.
//
// skip(): void
}
/**
@@ -349,23 +331,17 @@ export interface WidgetHandle<T = WidgetValue> {
*/
setHeight(px: number): void
// ── SERIALIZATION OPT-OUT — first-class, every-widget ────────────────────
/**
* Returns `true` if this widget is included in workflow and prompt
* serialization. Defaults to `true` for all widget types.
*
*/
isSerializeEnabled(): boolean
/**
* Enable or disable serialization for this widget. When disabled, the widget
* is excluded from both `widgets_values` in the workflow JSON and the API
* prompt payload. Equivalent to the v1 `widget.options.serialize = false`
* pattern.
*
*/
setSerializeEnabled(enabled: boolean): void
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
// Authors cannot disable serialization at the widget level (A16). If a
// widget should not contribute to the serialized payload, it should not
// be a widget (A15) — use a boxed/composed widget (BBOX-style), a
// non-widget UI primitive, or a schema input. The sole serialization
// interface is `widget.on('beforeSerialize', handler)`. Restoration
// requires axiom amendments to A15 + A16 + a validated ecosystem use
// case that no boxed/composed pattern can serve.
//
// isSerializeEnabled(): boolean
// setSerializeEnabled(enabled: boolean): void
// ── OPTIONS BAG — type-specific overrides ─────────────────────────────────
@@ -379,25 +355,23 @@ export interface WidgetHandle<T = WidgetValue> {
* {@link WidgetHandle.setOption} per-key.
*
* Note: this is an accessor pair on the v2 surface. Reading is free; the
* setter intentionally does not exist on the public type. v1 patterns like
* `widget.options.serialize = false` should migrate to
* {@link WidgetHandle.setSerializeEnabled}; `widget.options.values = [...]`
* (combo refresh) migrates to a future `setValues` mutator (tracked
* under W6.P8.UNMIGRATABLE).
* setter intentionally does not exist on the public type. Per AXIOMS.md
* §A16, `serialize` is no longer a writable option (and no longer a key
* on this bag) — there is no widget-level serialization disable.
* `widget.options.values = [...]` (combo refresh) migrates to a future
* `setValues` mutator (tracked under W6.P8.UNMIGRATABLE).
*
* @example
* ```ts
* // ❌ TS-ERR — every option write raises a compile-time error
* widget.options.min = 0
* widget.options = { min: 0, max: 100 }
* widget.options.serialize = false
*
* // ✅ Read freely
* const min = widget.options.min ?? 0
*
* // ✅ Mutate via typed setters
* widget.setOption('min', 0)
* widget.setSerializeEnabled(false)
* ```
*/
readonly options: Readonly<WidgetOptions>
@@ -456,9 +430,11 @@ export interface WidgetHandle<T = WidgetValue> {
* // ❌ TS-ERR — direct assignment no longer compiles
* widget.serializeValue = () => 'static value'
*
* // ✅ Subscribe to the typed event (D5)
* // ✅ Subscribe to the typed event (D5). Per A16 the hook fires once
* // and the framework writes the resulting payload to every transport;
* // there is no transport discriminator.
* widget.on('beforeSerialize', (e) => {
* if (e.context === 'prompt') e.setSerializedValue('static value')
* e.setSerializedValue('static value')
* })
* ```
*
@@ -495,28 +471,28 @@ export interface WidgetHandle<T = WidgetValue> {
handler: Handler<WidgetOptionChangeEvent>
): Unsubscribe
/**
* Subscribe to first-class property mutations (`setSerializeEnabled`).
*
* PHASE_A_EXCLUDED per AXIOMS.md A14: `setHidden`, `setDisabled` deferred
* pending serialization convergence.
*
* Does NOT fire for `setValue` (use `valueChange`) or options-bag mutations
* (use `optionChange`).
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'propertyChange',
handler: Handler<WidgetPropertyChangeEvent>
): Unsubscribe
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
// `WidgetPropertyChangeEvent` is vacuous — the only property the event
// ever surfaced was `'serialize'`, which is gone per A16. `setHidden` /
// `setDisabled` were already A14-deferred. Restoration requires a new
// first-class property to surface that satisfies A1A16.
//
// on(
// event: 'propertyChange',
// handler: Handler<WidgetPropertyChangeEvent>
// ): Unsubscribe
/**
* Subscribe to widget serialization. The only async-allowed event.
*
* Replaces `widget.serializeValue = fn` and the v1 `widget.options.serialize`
* flag. The handler may be sync or async; async handlers are awaited before
* Per AXIOMS.md §A16 this is the **sole** extension-author interface
* to serialization. The hook fires once per serialization and the
* framework writes the resulting payload to every transport (workflow
* JSON `widgets_values`, API prompt, clone target, subgraph promotion).
* Replaces the v1 `widget.serializeValue = fn` /
* `widget.options.serialize` patterns.
*
* The handler may be sync or async; async handlers are awaited before
* the serialization payload is sent.
*
* @returns A cleanup function to remove the listener.
@@ -630,8 +606,12 @@ export interface WidgetOptions {
hidden?: boolean
/** If `true`, the widget is rendered read-only (no user editing). */
readonly?: boolean
/** If `false`, this widget is excluded from workflow/prompt serialization. */
serialize?: boolean
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
// `serialize` contradicted A16 even as a read-only key — there is no
// widget-level serialization disable. Already write-blocked by
// D-immutability-enforcement; now removed from the type entirely.
//
// serialize?: boolean
/** Display label override. Defaults to the widget `name`. */
label?: string
/** Toggle label shown when value is `true` (BOOLEAN widgets). */

View File

@@ -42,7 +42,8 @@ import {
WidgetComponentContainer,
WidgetComponentDisplay,
WidgetComponentSchema,
WidgetComponentSerialize,
// WidgetComponentSerialize import removed per A16
// (D-widget-serialization-simplification, wave-9) — adapter blocks gone.
WidgetComponentValue
} from '@/world/widgets/widgetComponents'
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
@@ -299,20 +300,12 @@ function createWidgetHandle(widgetId: WidgetEntityId): WidgetHandle {
})
},
isSerializeEnabled() {
return (
world.getComponent(widgetId, WidgetComponentSerialize)?.serialize ??
true
)
},
setSerializeEnabled(enabled: boolean) {
dispatch({
type: 'SetWidgetOption',
widgetId,
key: 'serialize',
value: enabled
})
},
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
// Authors cannot disable serialization at the widget level (A16).
// Restoration requires axiom amendments to A15 + A16.
//
// isSerializeEnabled(): boolean { ... }
// setSerializeEnabled(enabled: boolean): void { ... }
// D-immutability-enforcement (Hybrid C): read-only snapshot of options
// bag. Public type is Readonly<WidgetOptions> — TS-ERR on any assignment.
@@ -359,7 +352,10 @@ function createWidgetHandle(widgetId: WidgetEntityId): WidgetHandle {
() => world.getComponent(widgetId, WidgetComponentValue)?.value,
(newValue, oldValue) => fn({ newValue, oldValue })
)
} else if (event === 'optionChange' || event === 'propertyChange') {
} else if (event === 'optionChange') {
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16 (D-widget-serialization-simplification, wave-9):
// `propertyChange` event removed alongside `setSerializeEnabled`
// (vacuous union after `'serialize'` was the sole member).
// TODO(#11939): wire through ECS event bus when available
if (import.meta.env.DEV) {
console.warn(