@glubean/sdk 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,9 +77,6 @@ type ContractNamespace = {
77
77
  */
78
78
  export declare function bootstrap<Needs, Params = void>(ref: ContractCaseRef<Needs, unknown>, spec: Bootstrap<Params, NoInfer<Needs>>): BootstrapAttachment<Needs, Params>;
79
79
  export declare const contract: ContractNamespace;
80
- /**
81
- * Protocol-agnostic flow builder. See contract-flow.md v9 §4.1.
82
- */
83
80
  export declare function flow(idOrMeta: string | FlowMeta): FlowBuilder<unknown>;
84
81
  /**
85
82
  * Core flow execution helper. Implements the Rule 1 / Rule 2 teardown
@@ -108,6 +105,21 @@ export declare function normalizeFlow<State>(runtime: RuntimeFlowProjection<Stat
108
105
  * losing projection data.
109
106
  */
110
107
  export declare function extractMappings(fn: (state: any) => any): FieldMapping[];
108
+ /**
109
+ * Run a single-selector lens `fn: (state) => state.a.b.c` through the strict
110
+ * tracing Proxy and return the read path (root segment dropped), e.g.
111
+ * `s => s.lookup.status` → `["lookup", "status"]`.
112
+ *
113
+ * Used by the condition/switch predicate builders (contract-flow-condition.ts)
114
+ * to capture a predicate's read path at construction time. The strict Proxy
115
+ * enforces purity (method calls / `new` / arithmetic-via-coercion throw
116
+ * `LensPurityError`); callers add a source-level single-selector gate on top
117
+ * to also reject ternaries / free-variable reads the Proxy can't see.
118
+ *
119
+ * Throws `LensPurityError` if the lens does not resolve to a single traced
120
+ * value (e.g. returns an object literal, a constant, or `undefined`).
121
+ */
122
+ export declare function extractSelectorPath(fn: (state: any) => any): string[];
111
123
  /**
112
124
  * Extract FieldMappings from `out: (state, response) => newState`.
113
125
  * Same purity contract as `extractMappings`.
@@ -1 +1 @@
1
- {"version":3,"file":"contract-core.d.ts","sourceRoot":"","sources":["../src/contract-core.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"contract-core.d.ts","sourceRoot":"","sources":["../src/contract-core.ts"],"names":[],"mappings":"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"}
@@ -19,6 +19,7 @@
19
19
  * - `internal/40-discovery/proposals/contract-flow.md` v9
20
20
  */
21
21
  import { registerTest } from "./internal.js";
22
+ import { selectBranchSteps, extractBranchStep, predicateScope, assertSelectorSource, assertSwitchCaseValues, assertL2Predicate, } from "./contract-flow-condition.js";
22
23
  import { getBootstrap, registerBootstrap } from "./bootstrap-registry.js";
23
24
  import { getExplicitInput, getBootstrapInput, isForceStandalone, } from "./runner-input-channel.js";
24
25
  /**
@@ -570,6 +571,166 @@ export const contract = {
570
571
  /**
571
572
  * Protocol-agnostic flow builder. See contract-flow.md v9 §4.1.
572
573
  */
574
+ // =============================================================================
575
+ // Branch desugar helpers (condition / switchOn / switchCond → one "branch" step)
576
+ //
577
+ // Shared by both FlowBuilder (top-level steps) and FlowFragmentBuilder (branch
578
+ // sub-steps). Each `then`/`else`/`default`/case callback is run against a fresh
579
+ // step sink whose accumulated RuntimeFlowStep[] becomes that branch's steps.
580
+ // =============================================================================
581
+ /** Build a contract-call runtime step (adapter lookup + flow-safety validation). */
582
+ function buildContractCallStep(flowId, ref, bindings) {
583
+ const adapter = _adapters.get(ref.protocol);
584
+ if (!adapter) {
585
+ throw new Error(`contract.flow(${JSON.stringify(flowId)}).step: unknown protocol "${ref.protocol}". ` +
586
+ `Did you forget to import a contract plugin package (e.g. "@glubean/grpc")?`);
587
+ }
588
+ if (!adapter.executeCaseInFlow) {
589
+ throw new Error(`contract.flow(${JSON.stringify(flowId)}).step: adapter for "${ref.protocol}" ` +
590
+ `does not implement executeCaseInFlow — this protocol cannot appear in a flow.`);
591
+ }
592
+ // v10: flow-safety validation fires at step-declaration time (not .case()).
593
+ const contractRef = ref.contract;
594
+ adapter.validateCaseForFlow?.(contractRef._spec, ref.caseKey, ref.contractId);
595
+ return {
596
+ kind: "contract-call",
597
+ name: bindings?.name,
598
+ ref,
599
+ caseKey: ref.caseKey,
600
+ contract: ref.contract,
601
+ bindings: bindings
602
+ ? {
603
+ in: bindings.in,
604
+ out: bindings.out,
605
+ ...(bindings.accept ? { accept: bindings.accept } : {}),
606
+ }
607
+ : undefined,
608
+ };
609
+ }
610
+ /** Module-private key holding a fragment sink's accumulated step list. */
611
+ const FRAGMENT_STEPS = Symbol("glubean.fragment-steps");
612
+ /**
613
+ * Run a branch-body callback and return the steps of the fragment it RETURNS
614
+ * (not a closure-shared array). Because the sink is persistent/immutable (each
615
+ * method returns a NEW sink carrying `[...prev, step]`), the executed steps are
616
+ * exactly the chain that produced the returned, type-checked fragment. A block
617
+ * body that mutates then returns an earlier sink — e.g.
618
+ * `(b) => { b.compute(reshape); return b; }` — yields that earlier sink's
619
+ * (shorter) step list, so runtime matches what the type system saw. This keeps
620
+ * the `NoExtraKeys` / invariant convergence guarantees honest.
621
+ */
622
+ function collectFragmentSteps(flowId, fn) {
623
+ const result = fn(makeStepSink(flowId, []));
624
+ const out = result?.[FRAGMENT_STEPS];
625
+ if (!Array.isArray(out)) {
626
+ throw new Error(`flow "${flowId}": a branch body must return the fragment builder ` +
627
+ `(e.g. \`(b) => b.compute(...)\` or \`(b) => b\`); it returned a non-builder value`);
628
+ }
629
+ return out;
630
+ }
631
+ /**
632
+ * A persistent (immutable) FlowFragmentBuilder over a steps array. Every method
633
+ * returns a NEW sink carrying `[...steps, newStep]` — it never mutates `steps`.
634
+ * The accumulated list is read back (by `collectFragmentSteps`) via the
635
+ * module-private `FRAGMENT_STEPS` key, so only the RETURNED fragment's steps
636
+ * run. The phantom invariant brand exists only in the type.
637
+ */
638
+ function makeStepSink(flowId, steps) {
639
+ const extend = (step) => makeStepSink(flowId, [...steps, step]);
640
+ // Typed `any` so methods can return sinks; the public type is the cast below.
641
+ const sink = {
642
+ step(ref, bindings) {
643
+ return extend(buildContractCallStep(flowId, ref, bindings));
644
+ },
645
+ compute(fn) {
646
+ return extend({ kind: "compute", fn });
647
+ },
648
+ condition(spec, thenB, elseB) {
649
+ return extend(buildConditionStep(flowId, "L2", spec, thenB, elseB));
650
+ },
651
+ conditionFn(spec, thenB, elseB) {
652
+ return extend(buildConditionStep(flowId, "L1", spec, thenB, elseB));
653
+ },
654
+ conditionAsync(spec, thenB, elseB) {
655
+ return extend(buildConditionStep(flowId, "L0", spec, thenB, elseB));
656
+ },
657
+ switchOn(lens) {
658
+ return (cases, deflt) => extend(buildSwitchOnStep(flowId, lens, cases, deflt));
659
+ },
660
+ switchCond(cases, deflt) {
661
+ return extend(buildSwitchCondStep(flowId, cases, deflt));
662
+ },
663
+ };
664
+ // Non-enumerable, frozen step list snapshot for this sink.
665
+ Object.defineProperty(sink, FRAGMENT_STEPS, {
666
+ value: Object.freeze(steps),
667
+ enumerable: false,
668
+ });
669
+ return sink;
670
+ }
671
+ /**
672
+ * condition / conditionFn / conditionAsync → predicate-mode branch (1 case +
673
+ * default). L2 builds a declarative `BranchPredicate` via `predicateScope`;
674
+ * L1/L0 wrap the opaque fn and REQUIRE a message (type-level + this runtime
675
+ * guard, so `as any` / JS callers can't produce an unlabeled opaque gate).
676
+ */
677
+ function buildConditionStep(flowId, tier, spec, thenB, elseB) {
678
+ const thenSteps = collectFragmentSteps(flowId, thenB);
679
+ const elseSteps = elseB ? collectFragmentSteps(flowId, elseB) : [];
680
+ let predicate;
681
+ let message;
682
+ if (tier === "L2") {
683
+ predicate = spec.predicate(predicateScope());
684
+ assertL2Predicate(predicate, "condition"); // runtime brand: reject forged/opaque/non-JSON trees
685
+ message = spec.message;
686
+ }
687
+ else {
688
+ if (typeof spec.message !== "string" || spec.message.length === 0) {
689
+ throw new LensPurityError(tier === "L1" ? "conditionFn" : "conditionAsync", `opaque (${tier}) predicate requires a non-empty \`message\` — the projection marks ` +
690
+ `this branch as an opaque/dynamic gate and the message is its only human-facing label`);
691
+ }
692
+ message = spec.message;
693
+ predicate = { kind: "opaque", sync: tier === "L1", fn: spec.predicate };
694
+ }
695
+ return {
696
+ kind: "branch",
697
+ mode: "predicate",
698
+ cases: [{ ...(message ? { message } : {}), predicate, steps: thenSteps }],
699
+ default: elseSteps,
700
+ };
701
+ }
702
+ /** switchCond → predicate-mode branch (N cases + default), each case a declarative predicate. */
703
+ function buildSwitchCondStep(flowId, cases, deflt) {
704
+ return {
705
+ kind: "branch",
706
+ mode: "predicate",
707
+ cases: cases.map((c) => {
708
+ const predicate = c.when(predicateScope());
709
+ assertL2Predicate(predicate, "switchCond"); // switch is always L2 — reject forged/opaque trees
710
+ return { predicate, steps: collectFragmentSteps(flowId, c.then) };
711
+ }),
712
+ default: collectFragmentSteps(flowId, deflt),
713
+ };
714
+ }
715
+ /**
716
+ * switchOn → value-mode branch. The subject lens runs the same P0 source gate +
717
+ * strict-Proxy path extraction as `w.when` (so the decision-table projection
718
+ * can't lie), values must be unique + finite, and the lens is normalized to
719
+ * `(ctx, s)` for uniform runtime evaluation (it is evaluated exactly once).
720
+ */
721
+ function buildSwitchOnStep(flowId, lens, cases, deflt) {
722
+ void flowId;
723
+ assertSelectorSource(lens);
724
+ const path = extractSelectorPath(lens);
725
+ assertSwitchCaseValues(cases.map((c) => c.value));
726
+ return {
727
+ kind: "branch",
728
+ mode: "value",
729
+ subject: { lens: (_ctx, s) => lens(s), path },
730
+ cases: cases.map((c) => ({ value: c.value, steps: collectFragmentSteps(flowId, c.then) })),
731
+ default: collectFragmentSteps(flowId, deflt),
732
+ };
733
+ }
573
734
  export function flow(idOrMeta) {
574
735
  const meta = typeof idOrMeta === "string"
575
736
  ? { id: idOrMeta }
@@ -594,34 +755,7 @@ export function flow(idOrMeta) {
594
755
  return builder;
595
756
  },
596
757
  step(ref, bindings) {
597
- const adapter = _adapters.get(ref.protocol);
598
- if (!adapter) {
599
- throw new Error(`contract.flow(${JSON.stringify(meta.id)}).step: unknown protocol "${ref.protocol}". ` +
600
- `Did you forget to import a contract plugin package (e.g. "@glubean/grpc")?`);
601
- }
602
- if (!adapter.executeCaseInFlow) {
603
- throw new Error(`contract.flow(${JSON.stringify(meta.id)}).step: adapter for "${ref.protocol}" ` +
604
- `does not implement executeCaseInFlow — this protocol cannot appear in a flow.`);
605
- }
606
- // v10: flow-safety validation fires here (at step-declaration time),
607
- // not at .case() time. The ref itself is pure; only when it enters a
608
- // flow do we enforce adapter-specific rules like HTTP's "function-
609
- // valued body/params/headers can't resolve in flow mode". This lets
610
- // contract.bootstrap(ref) attach to the same ref (where function-
611
- // valued fields legitimately receive resolvedInput).
612
- const contractRef = ref.contract;
613
- adapter.validateCaseForFlow?.(contractRef._spec, ref.caseKey, ref.contractId);
614
- const step = {
615
- kind: "contract-call",
616
- name: bindings?.name,
617
- ref,
618
- caseKey: ref.caseKey,
619
- contract: ref.contract,
620
- bindings: bindings
621
- ? { in: bindings.in, out: bindings.out }
622
- : undefined,
623
- };
624
- steps.push(step);
758
+ steps.push(buildContractCallStep(meta.id, ref, bindings));
625
759
  return builder;
626
760
  },
627
761
  compute(fn) {
@@ -632,6 +766,28 @@ export function flow(idOrMeta) {
632
766
  steps.push(step);
633
767
  return builder;
634
768
  },
769
+ condition(spec, thenB, elseB) {
770
+ steps.push(buildConditionStep(meta.id, "L2", spec, thenB, elseB));
771
+ return builder;
772
+ },
773
+ conditionFn(spec, thenB, elseB) {
774
+ steps.push(buildConditionStep(meta.id, "L1", spec, thenB, elseB));
775
+ return builder;
776
+ },
777
+ conditionAsync(spec, thenB, elseB) {
778
+ steps.push(buildConditionStep(meta.id, "L0", spec, thenB, elseB));
779
+ return builder;
780
+ },
781
+ switchOn(lens) {
782
+ return (cases, deflt) => {
783
+ steps.push(buildSwitchOnStep(meta.id, lens, cases, deflt));
784
+ return builder;
785
+ };
786
+ },
787
+ switchCond(cases, deflt) {
788
+ steps.push(buildSwitchCondStep(meta.id, cases, deflt));
789
+ return builder;
790
+ },
635
791
  build() {
636
792
  return finalize();
637
793
  },
@@ -720,6 +876,32 @@ function stepProjectionToRegistry(step) {
720
876
  writes: step.writes,
721
877
  };
722
878
  }
879
+ if (step.kind === "branch") {
880
+ if (step.mode === "value") {
881
+ return {
882
+ kind: "branch",
883
+ mode: "value",
884
+ name: step.name,
885
+ subjectPath: step.subjectPath,
886
+ cases: step.cases.map((c) => ({
887
+ value: c.value,
888
+ steps: c.steps.map(stepProjectionToRegistry),
889
+ })),
890
+ default: step.default.map(stepProjectionToRegistry),
891
+ };
892
+ }
893
+ return {
894
+ kind: "branch",
895
+ mode: "predicate",
896
+ name: step.name,
897
+ cases: step.cases.map((c) => ({
898
+ message: c.message,
899
+ predicate: c.predicate,
900
+ steps: c.steps.map(stepProjectionToRegistry),
901
+ })),
902
+ default: step.default.map(stepProjectionToRegistry),
903
+ };
904
+ }
723
905
  return {
724
906
  kind: "contract-call",
725
907
  name: step.name,
@@ -729,6 +911,7 @@ function stepProjectionToRegistry(step) {
729
911
  target: step.target,
730
912
  inputs: step.inputs,
731
913
  outputs: step.outputs,
914
+ ...(step.accept ? { accept: step.accept } : {}),
732
915
  };
733
916
  }
734
917
  /**
@@ -742,81 +925,96 @@ export async function runFlow(flowContract, ctx) {
742
925
  ? await runtime.setup(ctx)
743
926
  : undefined;
744
927
  try {
745
- for (const step of runtime.steps) {
746
- if (step.kind === "compute") {
747
- // Compute: synchronous pure function. Enforce both syntactic and
748
- // value-level async rejection.
749
- if (step.fn.constructor?.name === "AsyncFunction") {
750
- throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
751
- `async functions are not allowed — compute must be synchronous and I/O-free`);
928
+ // Recursive step executor. `state` is the closure-level committed cell —
929
+ // branch sub-steps read and write the same `state`, so a taken branch can
930
+ // observe and mutate flow state exactly like a top-level step. Non-taken
931
+ // branches never run (and so never touch state). Branches nest via recursion.
932
+ const runSteps = async (steps) => {
933
+ for (const step of steps) {
934
+ if (step.kind === "branch") {
935
+ // Evaluate selector/predicates against the current committed state,
936
+ // then execute only the matched case's sub-steps (or default).
937
+ const taken = await selectBranchSteps(step, state, ctx);
938
+ await runSteps(taken);
939
+ continue;
752
940
  }
753
- const result = step.fn(state);
754
- if (result && typeof result.then === "function") {
755
- throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
756
- `returned a thenable (Promise or Promise-like) — compute must not return async values. ` +
757
- `If you need async initialization, use flow.setup() instead.`);
941
+ if (step.kind === "compute") {
942
+ // Compute: synchronous pure function. Enforce both syntactic and
943
+ // value-level async rejection.
944
+ if (step.fn.constructor?.name === "AsyncFunction") {
945
+ throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
946
+ `async functions are not allowed — compute must be synchronous and I/O-free`);
947
+ }
948
+ const result = step.fn(state);
949
+ if (result && typeof result.then === "function") {
950
+ throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
951
+ `returned a thenable (Promise or Promise-like) — compute must not return async values. ` +
952
+ `If you need async initialization, use flow.setup() instead.`);
953
+ }
954
+ state = result;
955
+ continue;
758
956
  }
759
- state = result;
760
- continue;
761
- }
762
- // contract-call branch
763
- const adapter = _adapters.get(step.ref.protocol);
764
- if (!adapter) {
765
- throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
766
- `no registered adapter for protocol "${step.ref.protocol}". ` +
767
- `Did you forget to import a contract plugin package?`);
768
- }
769
- if (!adapter.executeCaseInFlow) {
770
- throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
771
- `adapter for "${step.ref.protocol}" does not implement executeCaseInFlow`);
772
- }
773
- // v10 Phase 2d Step 2+3 follow-up (RFR v2 / v2.1 P1): enforce `needs`
774
- // schema at flow boundary, not just standalone. The conditional-tuple
775
- // `step()` signature catches TS authoring shape mismatches, but runtime
776
- // validation is the only line of defense against:
777
- // - `as any` / JS callers that bypass the TS check
778
- // - Zod parse / coerce / default semantics (schema can transform
779
- // the value, not just validate it)
780
- // - State drift producing invalid values at runtime despite valid
781
- // authoring types
782
- // Standalone overlay path already validates via the same helper in
783
- // `dispatchContract`'s test.fn closure; flow mirrors that to keep
784
- // `needs` a true contract semantic rather than a TS-only boundary.
785
- //
786
- // Two-branch guard:
787
- // needs present + bindings.in missing → throw (contract requires input)
788
- // needs present + bindings.in present → validate unconditionally
789
- // needs absent → no guard
790
- const contractSpec = step.contract._spec;
791
- const caseSpec = contractSpec?.cases?.[step.caseKey];
792
- const needsSchema = caseSpec?.needs;
793
- const hasIn = typeof step.bindings?.in === "function";
794
- if (needsSchema && !hasIn) {
795
- throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
796
- `case "${step.ref.contractId}.${step.caseKey}" declares \`needs\` ` +
797
- `but the step has no \`bindings.in\`. The TypeScript conditional ` +
798
- `tuple on FlowBuilder.step() usually prevents this at compile time; ` +
799
- `this runtime check catches \`as any\` / JS bypass. Provide an ` +
800
- `\`in: (state) => <logical input>\` binding or remove the \`needs\` ` +
801
- `schema from the case.`);
802
- }
803
- let resolvedInputs = step.bindings?.in?.(state);
804
- if (needsSchema) {
805
- resolvedInputs = validateNeedsOutput(needsSchema, resolvedInputs, {
806
- testId: `${step.ref.contractId}.${step.caseKey}`,
807
- source: "flow",
957
+ // contract-call branch
958
+ const adapter = _adapters.get(step.ref.protocol);
959
+ if (!adapter) {
960
+ throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
961
+ `no registered adapter for protocol "${step.ref.protocol}". ` +
962
+ `Did you forget to import a contract plugin package?`);
963
+ }
964
+ if (!adapter.executeCaseInFlow) {
965
+ throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
966
+ `adapter for "${step.ref.protocol}" does not implement executeCaseInFlow`);
967
+ }
968
+ // v10 Phase 2d Step 2+3 follow-up (RFR v2 / v2.1 P1): enforce `needs`
969
+ // schema at flow boundary, not just standalone. The conditional-tuple
970
+ // `step()` signature catches TS authoring shape mismatches, but runtime
971
+ // validation is the only line of defense against:
972
+ // - `as any` / JS callers that bypass the TS check
973
+ // - Zod parse / coerce / default semantics (schema can transform
974
+ // the value, not just validate it)
975
+ // - State drift producing invalid values at runtime despite valid
976
+ // authoring types
977
+ // Standalone overlay path already validates via the same helper in
978
+ // `dispatchContract`'s test.fn closure; flow mirrors that to keep
979
+ // `needs` a true contract semantic rather than a TS-only boundary.
980
+ //
981
+ // Two-branch guard:
982
+ // needs present + bindings.in missing → throw (contract requires input)
983
+ // needs present + bindings.in present → validate unconditionally
984
+ // needs absent → no guard
985
+ const contractSpec = step.contract._spec;
986
+ const caseSpec = contractSpec?.cases?.[step.caseKey];
987
+ const needsSchema = caseSpec?.needs;
988
+ const hasIn = typeof step.bindings?.in === "function";
989
+ if (needsSchema && !hasIn) {
990
+ throw new Error(`flow "${runtime.id}" step "${step.name ?? step.caseKey}": ` +
991
+ `case "${step.ref.contractId}.${step.caseKey}" declares \`needs\` ` +
992
+ `but the step has no \`bindings.in\`. The TypeScript conditional ` +
993
+ `tuple on FlowBuilder.step() usually prevents this at compile time; ` +
994
+ `this runtime check catches \`as any\` / JS bypass. Provide an ` +
995
+ `\`in: (state) => <logical input>\` binding or remove the \`needs\` ` +
996
+ `schema from the case.`);
997
+ }
998
+ let resolvedInputs = step.bindings?.in?.(state);
999
+ if (needsSchema) {
1000
+ resolvedInputs = validateNeedsOutput(needsSchema, resolvedInputs, {
1001
+ testId: `${step.ref.contractId}.${step.caseKey}`,
1002
+ source: "flow",
1003
+ });
1004
+ }
1005
+ const response = await adapter.executeCaseInFlow({
1006
+ ctx,
1007
+ contract: step.contract,
1008
+ caseKey: step.caseKey,
1009
+ resolvedInputs,
1010
+ ...(step.bindings?.accept ? { accept: step.bindings.accept } : {}),
808
1011
  });
1012
+ if (step.bindings?.out) {
1013
+ state = step.bindings.out(state, response);
1014
+ }
809
1015
  }
810
- const response = await adapter.executeCaseInFlow({
811
- ctx,
812
- contract: step.contract,
813
- caseKey: step.caseKey,
814
- resolvedInputs,
815
- });
816
- if (step.bindings?.out) {
817
- state = step.bindings.out(state, response);
818
- }
819
- }
1016
+ };
1017
+ await runSteps(runtime.steps);
820
1018
  }
821
1019
  finally {
822
1020
  if (runtime.teardown) {
@@ -843,6 +1041,67 @@ export async function runFlow(flowContract, ctx) {
843
1041
  * with step context so the author sees which step needs fixing.
844
1042
  */
845
1043
  export function normalizeFlow(runtime) {
1044
+ // Recursive single-step normalizer. Branch sub-steps (cases + default) are
1045
+ // normalized through the same helper, so nested branches and any lens-purity
1046
+ // diagnostics inside them are handled identically at every depth.
1047
+ const normalizeStep = (s, idx) => {
1048
+ const stepLabel = s.name ??
1049
+ (s.kind === "contract-call" ? `${s.contract._projection.id}#${s.caseKey}` : `step-${idx + 1}`);
1050
+ if (s.kind === "branch") {
1051
+ // Recurse over each case/default sub-step list. We re-index from 0 within
1052
+ // the branch for labeling; the branch name (if any) carries outer context.
1053
+ return extractBranchStep(s, (steps) => steps.map((sub, i) => normalizeStep(sub, i)));
1054
+ }
1055
+ if (s.kind === "compute") {
1056
+ try {
1057
+ const { reads, writes } = traceComputeFn(s.fn);
1058
+ return { kind: "compute", name: s.name, reads, writes };
1059
+ }
1060
+ catch (err) {
1061
+ // Wrap compute-tracer failures with the same step-context format
1062
+ // used for lens errors, so authoring mistakes are equally easy
1063
+ // to localize regardless of which tracer caught them.
1064
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (compute): ${err instanceof Error ? err.message : String(err)}`);
1065
+ }
1066
+ }
1067
+ let inputs;
1068
+ if (s.bindings?.in) {
1069
+ try {
1070
+ inputs = extractMappings(s.bindings.in);
1071
+ }
1072
+ catch (err) {
1073
+ if (err instanceof LensPurityError) {
1074
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (in lens): ${err.message}`);
1075
+ }
1076
+ throw err;
1077
+ }
1078
+ }
1079
+ let outputs;
1080
+ if (s.bindings?.out) {
1081
+ try {
1082
+ outputs = extractMappingsOut(s.bindings.out);
1083
+ }
1084
+ catch (err) {
1085
+ if (err instanceof LensPurityError) {
1086
+ throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (out lens): ${err.message}`);
1087
+ }
1088
+ throw err;
1089
+ }
1090
+ }
1091
+ return {
1092
+ kind: "contract-call",
1093
+ name: s.name,
1094
+ contractId: s.contract._projection.id,
1095
+ caseKey: s.caseKey,
1096
+ protocol: s.ref.protocol,
1097
+ target: s.ref.target,
1098
+ inputs,
1099
+ outputs,
1100
+ ...(s.bindings?.accept
1101
+ ? { accept: [...s.bindings.accept] }
1102
+ : {}),
1103
+ };
1104
+ };
846
1105
  return {
847
1106
  id: runtime.id,
848
1107
  protocol: "flow",
@@ -852,56 +1111,7 @@ export function normalizeFlow(runtime) {
852
1111
  ...(runtime.skip !== undefined ? { skip: runtime.skip } : {}),
853
1112
  ...(runtime.only !== undefined ? { only: runtime.only } : {}),
854
1113
  setupDynamic: runtime.setup ? true : undefined,
855
- steps: runtime.steps.map((s, idx) => {
856
- const stepLabel = s.name ??
857
- (s.kind === "contract-call" ? `${s.contract._projection.id}#${s.caseKey}` : `step-${idx + 1}`);
858
- if (s.kind === "compute") {
859
- try {
860
- const { reads, writes } = traceComputeFn(s.fn);
861
- return { kind: "compute", name: s.name, reads, writes };
862
- }
863
- catch (err) {
864
- // Wrap compute-tracer failures with the same step-context format
865
- // used for lens errors, so authoring mistakes are equally easy
866
- // to localize regardless of which tracer caught them.
867
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (compute): ${err instanceof Error ? err.message : String(err)}`);
868
- }
869
- }
870
- let inputs;
871
- if (s.bindings?.in) {
872
- try {
873
- inputs = extractMappings(s.bindings.in);
874
- }
875
- catch (err) {
876
- if (err instanceof LensPurityError) {
877
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (in lens): ${err.message}`);
878
- }
879
- throw err;
880
- }
881
- }
882
- let outputs;
883
- if (s.bindings?.out) {
884
- try {
885
- outputs = extractMappingsOut(s.bindings.out);
886
- }
887
- catch (err) {
888
- if (err instanceof LensPurityError) {
889
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (out lens): ${err.message}`);
890
- }
891
- throw err;
892
- }
893
- }
894
- return {
895
- kind: "contract-call",
896
- name: s.name,
897
- contractId: s.contract._projection.id,
898
- caseKey: s.caseKey,
899
- protocol: s.ref.protocol,
900
- target: s.ref.target,
901
- inputs,
902
- outputs,
903
- };
904
- }),
1114
+ steps: runtime.steps.map(normalizeStep),
905
1115
  };
906
1116
  }
907
1117
  /**
@@ -919,6 +1129,28 @@ export function extractMappings(fn) {
919
1129
  const result = fn(proxy);
920
1130
  return collectMappings(result, []);
921
1131
  }
1132
+ /**
1133
+ * Run a single-selector lens `fn: (state) => state.a.b.c` through the strict
1134
+ * tracing Proxy and return the read path (root segment dropped), e.g.
1135
+ * `s => s.lookup.status` → `["lookup", "status"]`.
1136
+ *
1137
+ * Used by the condition/switch predicate builders (contract-flow-condition.ts)
1138
+ * to capture a predicate's read path at construction time. The strict Proxy
1139
+ * enforces purity (method calls / `new` / arithmetic-via-coercion throw
1140
+ * `LensPurityError`); callers add a source-level single-selector gate on top
1141
+ * to also reject ternaries / free-variable reads the Proxy can't see.
1142
+ *
1143
+ * Throws `LensPurityError` if the lens does not resolve to a single traced
1144
+ * value (e.g. returns an object literal, a constant, or `undefined`).
1145
+ */
1146
+ export function extractSelectorPath(fn) {
1147
+ const result = fn(makeLensProxy("state"));
1148
+ if (!isTracedValue(result)) {
1149
+ throw new LensPurityError("state", "selector must read a single state path (e.g. s => s.a.b), not compute or repack a value");
1150
+ }
1151
+ const full = result[TRACE_MARKER]; // "state.lookup.status"
1152
+ return full.split(".").slice(1); // drop "state" root
1153
+ }
922
1154
  /**
923
1155
  * Extract FieldMappings from `out: (state, response) => newState`.
924
1156
  * Same purity contract as `extractMappings`.