diff --git a/tools/workflow-engine/world.test.ts b/tools/workflow-engine/world.test.ts index 6f8361e7a3..3b25db93a0 100644 --- a/tools/workflow-engine/world.test.ts +++ b/tools/workflow-engine/world.test.ts @@ -16,6 +16,9 @@ import { type ComposedKey, type LifetimeState, type StandardVerdict, + type World, + type WorldTransitionFeedback, + type WorldTransitionResult, } from "./world"; interface WorkflowLifetime extends LifetimeState { @@ -254,4 +257,51 @@ describe("world substrate + reusable lifetime composition helpers", () => { expect(world.registry.has("workflow-review")).toBe(true); expect(world.registry.has("workflow-encryption")).toBe(true); }); + + it("registerLifetimePair preserves subclass fields under structural typing", () => { + // Regression test for spread-replace pattern: returning bare + // { registry: newRegistry } would silently drop subclass fields. + // Generic-over-W signature + spread preserves them. + interface SpecializedWorld extends World { + readonly tag: "specialized"; + readonly contextId: string; + } + const specialized: SpecializedWorld = { + ...EMPTY_WORLD, + tag: "specialized", + contextId: "ctx-1", + }; + const matrix = defaultAdvanceMatrix(workflowUniverse, reviewUniverse); + const after = registerLifetimePair(specialized, "pair", matrix); + // Registry updated + expect(after.registry.size).toBe(1); + // Subclass fields survive (compile-time: TS allows .tag + .contextId + // access because return type is SpecializedWorld, not bare World) + expect(after.tag).toBe("specialized"); + expect(after.contextId).toBe("ctx-1"); + }); + + it("dispatchInWorld returns WorldTransitionResult exhaustively switchable", () => { + // Regression test for Thread 7: exported feedback union lets + // callers switch exhaustively on the complete world-dispatch + // failure modes (TransitionFeedback variants + UnregisteredPair). + const result: WorldTransitionResult = dispatchInWorld( + EMPTY_WORLD, + "nonexistent-pair", + { kind: "draft" } as WorkflowLifetime, + { kind: "pending" } as ReviewLifetime, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected ok=false"); + // Exhaustive switch over WorldTransitionFeedback variants + const summarize = (fb: WorldTransitionFeedback): string => { + switch (fb.kind) { + case "UnregisteredPair": return `unregistered:${fb.pairName}`; + case "UndefinedComposedTransition": return `undefined-composed:${fb.composedKey}`; + case "InvalidStateA": return `invalid-a:${fb.reason}`; + case "InvalidStateB": return `invalid-b:${fb.reason}`; + } + }; + expect(summarize(result.feedback)).toBe("unregistered:nonexistent-pair"); + }); }); diff --git a/tools/workflow-engine/world.ts b/tools/workflow-engine/world.ts index ade73d0d4b..c54424df7d 100644 --- a/tools/workflow-engine/world.ts +++ b/tools/workflow-engine/world.ts @@ -50,6 +50,7 @@ import { type ComposedKey, type ComposedLifetimeContext, type LifetimeState, + type TransitionFeedback, type TransitionResult, } from "./composed-lifetime"; @@ -63,6 +64,7 @@ export { type ComposedKey, type ComposedLifetimeContext, type LifetimeState, + type TransitionFeedback, type TransitionResult, }; @@ -78,6 +80,26 @@ export { * world holds a registry of composed-lifetime matrices keyed by * lifetime-pair name. Caller registers matrices when introducing new * lifetime pairs; dispatch lookups go through the world's registry. + * + * Type-safety scope-disclosure (substrate-honest): `registry` stores + * matrices erased to `ReadonlyMap` keyed by pair-name + * string. `lookupLifetimePair` + `dispatchInWorld` + * cast at the lookup boundary using the generic arguments the caller + * supplies — TypeScript accepts the cast regardless of what the + * registered matrix actually holds. This means a caller can register + * one verdict type under `"pair-a"` and later look it up with a + * different `T` without a compile error; the runtime shape will not + * match the type the caller expects. + * + * Substrate-engineering target (not PoC scope): a typed-token API + * (`PairToken` carrying phantom-type witnesses; `definePair` + * + `registerPair` + `lookupPair` taking tokens; `dispatchByToken`) + * would preserve types end-to-end. The existing string-keyed surface + * stays as escape-hatch for substrate-engineering work that needs + * the string-key shape (e.g., dynamic registration from config / + * external substrate). Production callers should reach for the typed + * substrate when it lands; the string-keyed surface earns its keep + * during PoC + as an explicit-cast escape-hatch only. */ export interface World { readonly registry: ReadonlyMap>; @@ -245,6 +267,33 @@ export function predicateMatrix< return result; } +/** + * World-level dispatch feedback — extends base TransitionFeedback with + * the unregistered-pair failure mode unique to world-scope dispatch. + * + * Per asymmetric-authorship: substrate-entity (the world) authors the + * complete TFeedback channel its callers must handle. Exporting this + * union lets downstream consumers do exhaustive `switch` on the full + * world-dispatch feedback shape instead of ad-hoc narrowing on an + * inline return-type extension. Composes with base TransitionFeedback + * (per composed-lifetime.ts) which covers the lower-level + * undefined-transition / invalid-state-A / invalid-state-B classes. + */ +export type WorldTransitionFeedback = + | TransitionFeedback + | { kind: "UnregisteredPair"; pairName: string }; + +/** + * World-level dispatch result-shape per monad-propagation rule. + * + * Wraps the verdict-or-feedback discriminated union so callers can + * pattern-match exhaustively on a single named type instead of + * stitching together base TransitionResult + the world-extension. + */ +export type WorldTransitionResult = + | { ok: true; verdict: T; fromKey: string } + | { ok: false; feedback: WorldTransitionFeedback }; + /** * World-level dispatch: look up the matrix by pair name + dispatch * the composed transition. @@ -262,7 +311,7 @@ export function dispatchInWorld< pairName: string, a: A, b: B, -): TransitionResult | { ok: false; feedback: { kind: "UnregisteredPair"; pairName: string } } { +): WorldTransitionResult { const matrix = lookupLifetimePair(world, pairName); if (matrix === undefined) { return { ok: false, feedback: { kind: "UnregisteredPair", pairName } };