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
50 changes: 50 additions & 0 deletions tools/workflow-engine/world.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
type ComposedKey,
type LifetimeState,
type StandardVerdict,
type World,
type WorldTransitionFeedback,
type WorldTransitionResult,
} from "./world";

interface WorkflowLifetime extends LifetimeState {
Expand Down Expand Up @@ -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<StandardVerdict> = 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");
});
});
51 changes: 50 additions & 1 deletion tools/workflow-engine/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
type ComposedKey,
type ComposedLifetimeContext,
type LifetimeState,
type TransitionFeedback,
type TransitionResult,
} from "./composed-lifetime";

Expand All @@ -63,6 +64,7 @@ export {
type ComposedKey,
type ComposedLifetimeContext,
type LifetimeState,
type TransitionFeedback,
type TransitionResult,
};

Expand All @@ -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<string, unknown>` keyed by pair-name
* string. `lookupLifetimePair<A, B, T>` + `dispatchInWorld<A, B, T>`
* 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<A, B, T>` 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<string, ReadonlyMap<string, unknown>>;
Expand Down Expand Up @@ -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<T> =
| { ok: true; verdict: T; fromKey: string }
| { ok: false; feedback: WorldTransitionFeedback };

/**
* World-level dispatch: look up the matrix by pair name + dispatch
* the composed transition.
Expand All @@ -262,7 +311,7 @@ export function dispatchInWorld<
pairName: string,
a: A,
b: B,
): TransitionResult<T> | { ok: false; feedback: { kind: "UnregisteredPair"; pairName: string } } {
): WorldTransitionResult<T> {
const matrix = lookupLifetimePair<A, B, T>(world, pairName);
if (matrix === undefined) {
return { ok: false, feedback: { kind: "UnregisteredPair", pairName } };
Expand Down
Loading