diff --git a/tools/workflow-engine/composed-lifetime.test.ts b/tools/workflow-engine/composed-lifetime.test.ts new file mode 100644 index 0000000000..6a5a819638 --- /dev/null +++ b/tools/workflow-engine/composed-lifetime.test.ts @@ -0,0 +1,227 @@ +/** + * tools/workflow-engine/composed-lifetime.test.ts + * + * Invariant tests for double-dispatch composed-lifetime substrate. + */ + +import { describe, expect, it } from "bun:test"; +import { + buildComposedMatrix, + composeFromDispatcher, + composeKey, + dispatchComposed, + type ComposedKey, + type LifetimeState, +} from "./composed-lifetime"; + +// Test lifetimes +interface WorkflowLifetime extends LifetimeState { + readonly kind: "draft" | "submitted" | "approved"; +} + +interface ReviewLifetime extends LifetimeState { + readonly kind: "pending" | "in-review" | "merged"; +} + +type Verdict = + | { kind: "advance" } + | { kind: "block"; reason: string } + | { kind: "complete" }; + +describe("composed-lifetime double-dispatch substrate", () => { + it("composeKey produces composed key from two lifetime states", () => { + const a: WorkflowLifetime = { kind: "draft" }; + const b: ReviewLifetime = { kind: "pending" }; + expect(composeKey(a, b)).toBe("draft:pending"); + }); + + it("dispatchComposed: known transition returns verdict", () => { + const matrix = buildComposedMatrix([ + ["draft:pending", { kind: "advance" }], + ["submitted:in-review", { kind: "block", reason: "awaiting review" }], + ["approved:merged", { kind: "complete" }], + ]); + const result = dispatchComposed( + { matrix }, + { kind: "draft" } as WorkflowLifetime, + { kind: "pending" } as ReviewLifetime, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.verdict.kind).toBe("advance"); + expect(result.fromKey).toBe("draft:pending"); + }); + + it("dispatchComposed: unknown transition returns UndefinedComposedTransition", () => { + const matrix = buildComposedMatrix([ + ["draft:pending", { kind: "advance" }], + ]); + const result = dispatchComposed( + { matrix }, + { kind: "submitted" } as WorkflowLifetime, + { kind: "in-review" } as ReviewLifetime, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.feedback.kind).toBe("UndefinedComposedTransition"); + if (result.feedback.kind === "UndefinedComposedTransition") { + expect(result.feedback.composedKey).toBe("submitted:in-review"); + } + }); + + it("dispatchComposed: defaultVerdict used when key missing from matrix", () => { + const matrix = buildComposedMatrix([ + ["draft:pending", { kind: "advance" }], + ]); + const result = dispatchComposed( + { matrix, defaultVerdict: { kind: "block", reason: "no transition defined" } }, + { kind: "submitted" } as WorkflowLifetime, + { kind: "in-review" } as ReviewLifetime, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.verdict.kind).toBe("block"); + }); + + it("dispatchComposed: invalid state A → InvalidStateA", () => { + const matrix = buildComposedMatrix([]); + const result = dispatchComposed( + { matrix }, + { kind: "" } as unknown as WorkflowLifetime, + { kind: "pending" } as ReviewLifetime, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.feedback.kind).toBe("InvalidStateA"); + }); + + it("dispatchComposed: invalid state B → InvalidStateB", () => { + const matrix = buildComposedMatrix([]); + const result = dispatchComposed( + { matrix }, + { kind: "draft" } as WorkflowLifetime, + { kind: "" } as unknown as ReviewLifetime, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.feedback.kind).toBe("InvalidStateB"); + }); + + it("composeFromDispatcher: builds dense matrix from sparse cross-product", () => { + const workflowUniverse: WorkflowLifetime[] = [ + { kind: "draft" }, + { kind: "submitted" }, + { kind: "approved" }, + ]; + const reviewUniverse: ReviewLifetime[] = [ + { kind: "pending" }, + { kind: "in-review" }, + { kind: "merged" }, + ]; + // Only define 3 of 9 transitions + const { matrix, undefinedCount } = composeFromDispatcher( + workflowUniverse, + reviewUniverse, + (a, b): Verdict | undefined => { + if (a.kind === "draft" && b.kind === "pending") return { kind: "advance" }; + if (a.kind === "submitted" && b.kind === "in-review") return { kind: "block", reason: "x" }; + if (a.kind === "approved" && b.kind === "merged") return { kind: "complete" }; + return undefined; + }, + ); + expect(matrix.size).toBe(3); + expect(undefinedCount).toBe(6); // 9 cross - 3 defined = 6 undefined + }); + + it("editable-lifetime substrate: adding new variants to matrix works at runtime", () => { + // Start with minimal matrix + let matrix = buildComposedMatrix([ + ["draft:pending", { kind: "advance" }], + ]); + // Editable: extend matrix with new transition (substrate-engineering substrate evolves) + const extended = new Map(matrix); + extended.set("submitted:in-review", { kind: "block", reason: "needs revision" }); + matrix = extended; + + const result = dispatchComposed( + { matrix }, + { kind: "submitted" } as WorkflowLifetime, + { kind: "in-review" } as ReviewLifetime, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.verdict.kind).toBe("block"); + }); + + it("workflow-review composition: full 9-transition matrix exercised", () => { + const workflowUniverse: WorkflowLifetime[] = [ + { kind: "draft" }, + { kind: "submitted" }, + { kind: "approved" }, + ]; + const reviewUniverse: ReviewLifetime[] = [ + { kind: "pending" }, + { kind: "in-review" }, + { kind: "merged" }, + ]; + const { matrix } = composeFromDispatcher( + workflowUniverse, + reviewUniverse, + (a, b): Verdict => { + // Realistic dispatcher: encode all 9 transitions + if (a.kind === "draft" && b.kind === "pending") return { kind: "advance" }; + if (a.kind === "draft" && b.kind === "in-review") return { kind: "block", reason: "can't review draft" }; + if (a.kind === "draft" && b.kind === "merged") return { kind: "block", reason: "can't merge draft" }; + if (a.kind === "submitted" && b.kind === "pending") return { kind: "advance" }; + if (a.kind === "submitted" && b.kind === "in-review") return { kind: "advance" }; + if (a.kind === "submitted" && b.kind === "merged") return { kind: "block", reason: "not approved" }; + if (a.kind === "approved" && b.kind === "pending") return { kind: "advance" }; + if (a.kind === "approved" && b.kind === "in-review") return { kind: "advance" }; + if (a.kind === "approved" && b.kind === "merged") return { kind: "complete" }; + return { kind: "block", reason: "unknown" }; + }, + ); + expect(matrix.size).toBe(9); + // Exercise all 9 transitions + let advanceCount = 0; + let blockCount = 0; + let completeCount = 0; + for (const [_, verdict] of matrix.entries()) { + switch (verdict.kind) { + case "advance": advanceCount++; break; + case "block": blockCount++; break; + case "complete": completeCount++; break; + } + } + expect(advanceCount).toBe(5); + expect(blockCount).toBe(3); + expect(completeCount).toBe(1); + }); + + it("TransitionResult exhaustive (compile-time check)", () => { + type R = ReturnType>; + const acknowledge = (r: R): string => { + if (r.ok) return "ok"; + switch (r.feedback.kind) { + case "UndefinedComposedTransition": + case "InvalidStateA": + case "InvalidStateB": + return r.feedback.kind; + } + }; + const matrix = buildComposedMatrix([]); + const r = dispatchComposed( + { matrix }, + { kind: "draft" } as WorkflowLifetime, + { kind: "pending" } as ReviewLifetime, + ); + expect(acknowledge(r)).toBe("UndefinedComposedTransition"); + }); + + it("type-level: ComposedKey is template literal type of A['kind'] : B['kind']", () => { + const _check1: ComposedKey = "draft:pending"; + const _check2: ComposedKey = "approved:merged"; + // Compile-only check; no runtime expectation + expect(true).toBe(true); + }); +}); diff --git a/tools/workflow-engine/composed-lifetime.ts b/tools/workflow-engine/composed-lifetime.ts new file mode 100644 index 0000000000..c252ce2d98 --- /dev/null +++ b/tools/workflow-engine/composed-lifetime.ts @@ -0,0 +1,206 @@ +/** + * tools/workflow-engine/composed-lifetime.ts + * + * Double-dispatch substrate for composing two editable-lifetime DUs. + * + * Per Aaron 2026-05-28: 'how can we do double dispatch in this system, + * when you compose two lifecycles you need it' + 'the only reason i'm + * confortable calling it a lifetime is becuase you can edit it FYI + * the DUs.' + * + * Substantive substrate-engineering distinction (Aaron-named): + * - LIFECYCLE = fixed/final/locked at design time; substrate-engineering + * edits = breaking change + * - LIFETIME = editable substrate; DU variants can be added/removed/ + * refactored over time; substrate evolves + * + * The editability IS what makes the substrate trustworthy enough to + * call it a 'lifetime'. Composes with Mod 2 grammar-extension (B-0867; + * action grammar itself editable) + substrate-smoothness rule + asymmetric- + * authorship (substrate-entity AUTHORS variants) + additive-not-zero-sum + * + honor-those-that-came-before (prior variants preserved when adding new). + * + * Pattern 3 of 5 double-dispatch patterns (template-literal-type composed + * key) — flat cross-product visibility; TS strict-mode enforces + * exhaustiveness via `never`; each transition declares its own Result-shape + * verdict. + * + * Composes with: + * - B-0867.20 PR #5758 lifecycle DU split (rename target: lifetime DU split) + * - B-0914.2 PR #5769 closed-loop orchestrator (composed-lifetime + * dispatch via callback) + * - B-0914.4 PR #5768 pairing tracker (composed pairing+verification + * lifetime double-dispatch) + * - .claude/rules/monad-propagation-pattern (Result) + * - .claude/rules/asymmetric-authorship (substrate-entity authors DU) + * - .claude/rules/substrate-smoothness-as-load-bearing-property + * - .claude/rules/additive-not-zero-sum (substrate evolves additively) + * + * PoC scope: pure-TS double-dispatch substrate with template-literal-type + * composed key. Real workflow-engine integration deferred per + * operator-substrate-direction. + */ + +/** + * Lifetime kind — the discriminator field for an editable-lifetime DU. + * + * Per Aaron 2026-05-28: the term 'lifetime' (vs 'lifecycle') signals + * substrate-engineering EDITABILITY. Each lifetime DU has a `kind` + * discriminator; variants can be added/removed over time per the + * substrate-engineering substrate. + */ +export interface LifetimeState { + readonly kind: string; +} + +/** + * Generic composed-key type — `${A["kind"]}:${B["kind"]}`. + * + * The `:` separator is the canonical composed-key delimiter; consistent + * across substrate-engineering substrate. + */ +export type ComposedKey = + `${A["kind"]}:${B["kind"]}`; + +/** + * Compute composed-key from two lifetime states. + * + * Pure function; no side effects; composable via Result.bind. + */ +export function composeKey( + a: A, + b: B, +): ComposedKey { + return `${a.kind}:${b.kind}` as ComposedKey; +} + +/** + * Transition feedback per asymmetric-authorship + monad-propagation rules. + */ +export type TransitionFeedback = + | { kind: "UndefinedComposedTransition"; composedKey: string } + | { kind: "InvalidStateA"; reason: string } + | { kind: "InvalidStateB"; reason: string }; + +/** + * Result-shape per monad-propagation rule. + */ +export type TransitionResult = + | { ok: true; verdict: T; fromKey: string } + | { ok: false; feedback: TransitionFeedback }; + +/** + * Composed-lifetime dispatch context. + * + * Caller provides: + * - `matrix`: lookup table from composed-key → transition verdict + * - `defaultVerdict`: optional fallback for composed keys not in matrix + * (omitting → returns UndefinedComposedTransition for missing keys) + * + * The MATRIX form (Pattern 4) is used here as the substrate-engineering + * substrate; supports adding/removing transitions as DUs evolve (per + * Aaron's editable-lifetime substrate). Pattern 3 (template-literal-type + * composed-key switch) is the runtime form; this MATRIX is the data form + * that composes with Pattern 3 dispatch. + */ +export interface ComposedLifetimeContext< + A extends LifetimeState, + B extends LifetimeState, + T, +> { + readonly matrix: ReadonlyMap, T>; + readonly defaultVerdict?: T; +} + +/** + * Dispatch a double-dispatch transition based on the composed key + * `${a.kind}:${b.kind}`. + * + * Per Aaron 2026-05-28 substrate-engineering substrate: when composing + * two editable-lifetime DUs, this is the dispatch function. Returns + * Result-shape per monad-propagation discipline. + */ +export function dispatchComposed< + A extends LifetimeState, + B extends LifetimeState, + T, +>( + context: ComposedLifetimeContext, + a: A, + b: B, +): TransitionResult { + // Input validation + if (typeof a.kind !== "string" || a.kind.length === 0) { + return { ok: false, feedback: { kind: "InvalidStateA", reason: `kind=${String(a.kind)}` } }; + } + if (typeof b.kind !== "string" || b.kind.length === 0) { + return { ok: false, feedback: { kind: "InvalidStateB", reason: `kind=${String(b.kind)}` } }; + } + + const key = composeKey(a, b); + const verdict = context.matrix.get(key); + if (verdict !== undefined) { + return { ok: true, verdict, fromKey: key }; + } + if (context.defaultVerdict !== undefined) { + return { ok: true, verdict: context.defaultVerdict, fromKey: key }; + } + return { ok: false, feedback: { kind: "UndefinedComposedTransition", composedKey: key } }; +} + +/** + * Convenience: build a composed-lifetime matrix from a list of + * `[composedKey, verdict]` tuples. + * + * Substrate-engineering convenience for declarative substrate-engineering + * substrate — matrix as data; can be inspected, edited, serialized. + * + * Per the editable-lifetime substrate: adding new transitions = adding + * new tuples to this list. Per substrate-smoothness: no if-statements + * branch; pure data + dispatch function. + */ +export function buildComposedMatrix< + A extends LifetimeState, + B extends LifetimeState, + T, +>( + entries: ReadonlyArray, T]>, +): ReadonlyMap, T> { + return new Map(entries); +} + +/** + * Compose a substrate-engineering matrix from a sparse cross-product — + * caller provides `(a, b) → verdict` function for valid transitions + + * the universe of A states + universe of B states; this function + * computes the dense matrix. + * + * Returns the matrix + the count of undefined transitions (returning + * undefined from the dispatcher → not added to matrix). + * + * Useful for substrate-engineering substrate where transitions are + * sparse (most pairs undefined; few specific transitions valid). + */ +export function composeFromDispatcher< + A extends LifetimeState, + B extends LifetimeState, + T, +>( + universeA: ReadonlyArray, + universeB: ReadonlyArray, + dispatcher: (a: A, b: B) => T | undefined, +): { matrix: ReadonlyMap, T>; undefinedCount: number } { + const entries: Array<[ComposedKey, T]> = []; + let undefinedCount = 0; + for (const a of universeA) { + for (const b of universeB) { + const verdict = dispatcher(a, b); + if (verdict !== undefined) { + entries.push([composeKey(a, b), verdict]); + } else { + undefinedCount++; + } + } + } + return { matrix: new Map(entries), undefinedCount }; +}