From 5d722a09aeacc44636b5a132e0b6303b462aec5e Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 11:43:00 -0400 Subject: [PATCH 01/29] =?UTF-8?q?docs:=20DB-18=20Part=201=20=E2=80=94=20Wo?= =?UTF-8?q?rkflowEffect=20substrate=20carrier=20(Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes Stage 2b's workflow control-flow shape from a lens-level re-derivation to a first-class substrate carrier. Four-variant coproduct (LinearEffect | BranchEffect | LoopEffect | ParallelEffect) with BranchArm helper record; Q4 dissolution receipt stamped inline; Q1–Q6 substrate-principle audit stamped inline. LinearEffect is the only Stage 2b consumer — other variants emit C-8 fail-closed diagnostics naming the downstream stage. Coexists orthogonally with PR #529's CompositionVerdict (input structure vs output verdict; no enclosing record pairs them). Part 1 is design only; Part 2 implementation is gated on PR #529 landing (pre-start gate named in the doc). Also adds an additive subsection to lane2-compile-time-proofs.md §Stage 2b pointing at DB-18 — placed between Escalation and Pre-start gate, both of which PR #529 leaves untouched — so merges without conflict. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design-db18-workflow-effect-carrier.md | 382 ++++++++++++++++++++ docs/lane2-compile-time-proofs.md | 16 + 2 files changed, 398 insertions(+) create mode 100644 docs/design-db18-workflow-effect-carrier.md diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md new file mode 100644 index 00000000000..27e6ec09245 --- /dev/null +++ b/docs/design-db18-workflow-effect-carrier.md @@ -0,0 +1,382 @@ +> Part of: [lane2-compile-time-proofs.md](./lane2-compile-time-proofs.md) (Lane 2 Stage 2b) | Companion: [design-composed-effect-reshape.md](./design-composed-effect-reshape.md) (PR #529) | Unblocks: Lane 2 Stage 2b implementation (workflow idempotency lens); informs Lane 2 Stages 2d / 2e / 2f (downstream composition over workflow structure) + +# Design DB-18 — `WorkflowEffect` substrate carrier (Stage 2b input structure) + +**Design blocker:** DB-18 (new substrate carrier `WorkflowEffect` — a four-variant coproduct describing workflow control-flow shape for effect-algebra composition) +**Consumers:** Lane 2 Stage 2b workflow idempotency lens (initial consumer; `LinearEffect`-only scope). Downstream: Stage 2d symbolic cost, Stage 2e parallelism-as-lens, Stage 2f user-declared dimensions. +**Status:** Proposed. **Part 1 design-only — implementation and tests ship in the follow-up Part 2 PR.** +**Companion:** [design-composed-effect-reshape.md](./design-composed-effect-reshape.md) (PR #529) reshapes the *output* side of the effect algebra: `ComposedEffect` is removed; `compose_effects(effects: List) -> CompositionVerdict` is the post-reshape algebra. DB-18 adds the *input* side: a typed carrier describing the workflow's control-flow structure above `List`. The two live on orthogonal axes — `WorkflowEffect` is the input structure the lens walks; `CompositionVerdict` is the output the algebra returns. They coexist; neither replaces the other. + +--- + +## Summary + +Stage 2b's pre-DB-18 design (`lane2-compile-time-proofs.md` §Stage 2b) assumed a workflow is a linear `List` — `compose_effects` walks the list, composes effect shapes, emits a verdict. That shape is correct for the narrow case (GCP Secret Manager upsert → STS Exchange → IAM grant, all linear) but the escalation clause in the same section already names the extension: *"if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension."* + +DB-18 is that legitimate design extension, moved up-front rather than deferred. The substrate gains **one new reflected type** (`WorkflowEffect`) with **one helper record** (`BranchArm`); the computation substrate is untouched (no new `Behavior` variant, no `LoopBound` change); the effect algebra (`compose_effects`, `CompositionVerdict`) is untouched. Stage 2b's lens matches on `LinearEffect` only and emits explicit diagnostics on the other three variants, each naming the downstream stage that will consume it. The diagnostic is a first-class `Diagnostic`, not a silent skip — the fail-closed contract from INVARIANTS §C-8 applies. + +`WorkflowEffect` is the input-structure carrier. `CompositionVerdict` (PR #529) is the output-verdict carrier. They meet at exactly one edge: Stage 2b's lens projects `LinearEffect { ops }` through `compose_effects(ops)` to obtain a `CompositionVerdict`; other `WorkflowEffect` variants produce diagnostics without invoking the algebra. + +--- + +## Revision history + +- **R0** (rejected; pre-dispatch): "Stage 2b is lens-only — model workflows as `List` produced by the lens at walk time from the DAG's Bind chain." Rejected because it re-derives workflow structure heuristically from DAG shape at lens time, violating `feedback_lenses_not_passes` ("heuristic = missing physics"). A workflow's control-flow shape is an input fact to the algebra, not something to reconstruct. +- **R1** (current, proposed): Workflow control-flow shape is lifted into the substrate as a first-class carrier `WorkflowEffect`. Stage 2b's lens reads the carrier structurally and dispatches per variant. Four variants (`LinearEffect | BranchEffect | LoopEffect | ParallelEffect`) cover the four workflow-level control-flow topologies; recursion through `WorkflowEffect` fields composes them. `LinearEffect` is the only variant Stage 2b consumes; the other three emit diagnostic + name the downstream stage. + +--- + +## Constraints (non-negotiable) + +1. **`CompositionVerdict` authority preserved.** Post-PR #529, `compose_effects(List) -> CompositionVerdict` is the sole effect-algebra output. DB-18 does not introduce a parallel verdict carrier. `LinearEffect`'s lens delegates to `compose_effects`; other variants do not compose at Stage 2b. +2. **Five-behavior computation substrate untouched.** `Behavior = Value | Transform | Branch | Loop | Bind` remains at five variants. `WorkflowEffect` lives in the *type substrate* (declarations), not the computation substrate. Parallel lens tests (`test_thesis_five_behavior_variants`-style) stay green by construction. +3. **Bounded kernel invariant.** `WorkflowEffect` is recursive through its own variant fields; the recursion terminates at `LinearEffect.ops: NonEmptyList` (no further `WorkflowEffect` children). Finite by construction because the substrate is a DAG (no cycles in `WorkflowEffect`-typed edges). +4. **Illegal states unrepresentable at the type level.** Cardinality invariants use Track 9 primitives (`NonEmptyList`, `NonSingletonList`). No `List` with a "lowering guarantees ≥1" comment; no `Bool + Option` pairs that admit contradictions. +5. **Fail-closed at diagnostic boundaries (INVARIANTS §C-8).** Every branch that does not lead to a `CompositionVerdict` produces a `Diagnostic` explaining which variant was encountered, which downstream stage consumes it, and — if the user can reshape the workflow into a `LinearEffect` form — how. No `Option`, no `Result`, no silent skip. +6. **Stage 2a follow-up authority preserved.** The `CompositionVerdict`/`BreakingOperation`/`IdempotentShape`/`BreakingShape` partitioning landed in PR #529 is consumed by DB-18's `LinearEffect`-path without modification. DB-18 does not reshape `EffectShape`, does not re-materialize `ComposedEffect`, does not pair `CompositionVerdict` with a sibling list field (which would reintroduce the correlated-fields incoherence PR #529 dissolved). + +--- + +## Design + +### Substrate changes + +**Add the `WorkflowEffect` coproduct** (`src/v3/std/effects.dag`): + +```dag +// 🟢 TERMINAL. Workflow control-flow structure at the effect level. +// The compiler already distinguishes "do these ops in sequence" from +// "do one of these arms depending on a condition" from "repeat this +// body" at the computation-substrate level (Bind chains / Branch / +// Loop behaviors). This carrier hoists that distinction to the effect +// algebra so compose_effects and its downstream peers (symbolic cost, +// parallelism, user dimensions) can dispatch on shape rather than +// reconstruct it by walking the DAG. +// +// Four variants cover the four categorical operations on effect +// sequences: monoidal composition (LinearEffect, ∘), coproduct +// (BranchEffect, ∨), fixpoint (LoopEffect, μ), product/concurrent +// (ParallelEffect, ⊗). The Q4 dissolution receipt below argues each +// variant traces to a distinct algebraic operation and the coproduct +// is structurally irreducible. +// +// Recursive composition: BranchEffect, LoopEffect, and ParallelEffect +// each carry sub-WorkflowEffect fields, so arbitrary nesting composes. +// LinearEffect terminates the recursion at a non-empty list of +// OperationEffect values — the same carrier compose_effects consumes +// post-PR-#529. +type WorkflowEffect + = LinearEffect { ops: NonEmptyList } + | BranchEffect { arms: NonSingletonList } + | LoopEffect { body: WorkflowEffect } + | ParallelEffect { branches: NonSingletonList } + +// 🟢 TERMINAL. A single arm of a BranchEffect — the condition port +// witnessing "this arm is taken" plus the workflow executed when the +// condition holds. Separating condition from workflow makes the +// payload structurally distinct from ParallelEffect's +// NonSingletonList, so the Q4 dissolution receipt +// passes at the payload level rather than requiring a separate +// discriminator tag. +// +// `condition: PortId` — a typed substrate handle into the computation +// DAG. The port is the producer of the Bool-typed value the branch +// dispatches on. Constructor-validated: `branch_arm_of(port, body)` +// returns `None` for non-Bool-typed ports, so a malformed arm is +// unrepresentable. +type BranchArm { + condition: PortId + body: WorkflowEffect +} +``` + +**Why this belongs in `std/effects.dag` rather than `std/substrate.dag`.** `WorkflowEffect` is a concept of the effect algebra — it describes input shapes that `compose_effects` (and its downstream peers) dispatch over. The substrate's own declarations (`Dag`, `Declaration`, `Behavior`, `LoopBound`, `Cluster`) describe the *computation and type substrate*; the effect algebra is a lens-writable layer above that substrate. The existing file already hosts `OperationEffect`, `EffectShape`, `IdempotencyEvidence`, `CompositionVerdict` — all effect-algebra types. `WorkflowEffect` joins that family. + +**No reflected `type Dag` changes.** `WorkflowEffect` values live inside user code (as fields of workflow declarations) and inside lens output, not as a sidecar table on `Dag`. The authority for "what workflow does this call site model?" is the user's declaration; the lens walks declarations and reads their `WorkflowEffect`-typed fields. No new `Dag.workflows: List<...>` field is needed (cf. DB-9 R2.1 `Dag.clusters` sidecar which is necessary because clusters span multiple declarations; a `WorkflowEffect` is a single declaration-local value). + +**No Rust mirror yet.** Because Part 1 is design-only, no `src/v3/compiler/src/dag.rs` change is proposed here. Part 2 mirrors `WorkflowEffect` and `BranchArm` in `dag.rs` under the same reflection-invariant check (`m2_field_access_binding_test.rs`) that DB-9 R2.1 used for `Cluster` / `MemberDescent` / `IntraClusterCall`. The single-authority discipline from DB-9 R2.1 carries over: lowering writes `WorkflowEffect` values; lenses and inference are pure readers. + +### Dissolution receipt — `WorkflowEffect` coproduct (four-pattern check) + +Per `feedback_coproduct_dissolution`, every new coproduct must pass the four-pattern check before being stamped `🟢 TERMINAL`. Stamped here, not deferred. The full argument is required under the user's directive ("Audit Q4 receipt required"). + +**Pattern 1 — Fact placement (multiple consumers, different DAG locations).** All four variants attach to the same `WorkflowEffect`-typed slot at the same DAG location — a workflow declaration's root. The variant discriminates KIND OF WORKFLOW SHAPE, not WHERE THE WORKFLOW FACT LIVES. No fact-placement compression to dissolve. ✓ does not apply. + +**Pattern 2 — Variant-is-data (same shape, different label).** The four variants' payloads, audited pairwise: + +| Variant A | Variant B | Payload A | Payload B | Distinct? | +|---|---|---|---|---| +| `LinearEffect` | `BranchEffect` | `NonEmptyList` | `NonSingletonList` | ✓ different element types + different cardinality contract | +| `LinearEffect` | `LoopEffect` | `NonEmptyList` | `WorkflowEffect` | ✓ list vs single | +| `LinearEffect` | `ParallelEffect` | `NonEmptyList` | `NonSingletonList` | ✓ different element types + different cardinality contract | +| `BranchEffect` | `LoopEffect` | `NonSingletonList` | `WorkflowEffect` | ✓ list vs single | +| `BranchEffect` | `ParallelEffect` | `NonSingletonList` | `NonSingletonList` | ✓ `BranchArm` ≠ `WorkflowEffect` (BranchArm carries `condition: PortId` that ParallelEffect branches do not) | +| `LoopEffect` | `ParallelEffect` | `WorkflowEffect` | `NonSingletonList` | ✓ single vs list | + +The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonList<...>` at the outer shape and recurse on `WorkflowEffect` at the element level. The distinction is carried by `BranchArm { condition: PortId, body: WorkflowEffect }` versus a plain `WorkflowEffect`: a branch arm is an arm-gated-by-condition, a parallel branch is an unconditioned concurrent workflow. The `condition: PortId` is the load-bearing structural fact — it encodes "which arm is taken is a function of this specific DAG port's value," which is irreducibly different from "all branches execute concurrently and converge at a join." Pattern 2's dissolution criterion ("same shape, different label") does not apply because the BranchArm wrapper carries a structurally required field ParallelEffect's branches do not. ✓ does not apply. + +**Pattern 3 — Algebraic-form (traces to intro/elim of algebraic structures).** The four variants trace to four distinct categorical operations on effect algebras: + +- `LinearEffect` = **monoidal composition (∘)**. `compose_effects` already witnesses this: a linear sequence of effects composes as `∘` with `ReadEffect` (identity on state) as the unit. The `CompositionVerdict` is the algebra's output for this case. The carrier `NonEmptyList` is the free monoid on `OperationEffect` less the empty word. +- `BranchEffect` = **coproduct (∨) / lattice meet over arms**. Exactly one arm executes at runtime; the verdict must hold for whichever arm is taken. At compile time this is a `∨`-over-arms: the workflow is idempotent iff every arm's workflow is idempotent. Structurally distinct from `∘` — composition order does not matter for arms that are alternatives, and the per-arm verdicts combine via lattice meet, not via monoidal multiplication. +- `LoopEffect` = **fixpoint (μ) / iteration**. The body is re-applied 0..N times (bound structure tracked separately, potentially in Part 2). Idempotency under iteration demands the body itself be idempotent (`f ∘ f = f`) — a strictly stronger condition than linear composition, because monoidal composition of two different non-idempotent effects can still converge, but iteration of one non-idempotent effect cannot. +- `ParallelEffect` = **concurrent product (⊗) / commutative composition**. Branches execute concurrently and require algebraic commutativity on the target state to compose safely. The verdict's validity depends on commutativity evidence (open question §1 below — Part 2 or Stage 2e may extend the carrier with a `commutativity` witness). Distinct from both `∘` (order-preserving) and `∨` (alternatives): `⊗` is unordered AND concurrent. + +Each variant is the introduction form of a different categorical operation. There is no single algebraic operation the four dissolve into; mapping the set of four to a two-dimensional record (e.g., `{ordered, concurrent}`) does not recover the distinct verdict rules each variant demands. ✓ does not apply. + +**Pattern 4 — Dimensional (flat enum hides M-dimensional record).** The proposed dimension would be `{control_flow: Linear | Branch | Loop | Parallel, ...}` — but this is just the coproduct tag renamed, with no second coordinate. Attempting a second coordinate produces a partial record where each variant has different required fields (LinearEffect needs `ops`; BranchEffect needs `arms` with conditions; LoopEffect needs `body`; ParallelEffect needs `branches`): these are different types of payload, not orthogonal coordinates on a shared record. A record shape with every payload field present-or-absent would admit illegal combinations (`control_flow: LinearEffect` with `arms: Some(...)`), which violates the Q1 cardinality-invariant check. ✓ does not apply. + +**Conclusion:** `WorkflowEffect`'s 4-variant coproduct is structurally irreducible. The coproduct is the correct shape. Stamp: `🟢 TERMINAL` on `WorkflowEffect`, `🟢 TERMINAL` on `BranchArm`. + +### Substrate principle audit (Q1–Q6, beyond Q4) + +Per `feedback_substrate_principle_audit`, all six questions walk before greenlighting. Q4 is covered above; the other five are stamped here so downstream review verifies the audit ran rather than re-deriving each finding. + +**Q1 — Cardinality invariants.** Does any variant admit `[]` where invariant says ≥1, or singletons where ≥2? + +- `LinearEffect.ops: NonEmptyList` — a linear workflow with zero ops is meaningless (nothing to compose). Type rejects `[]`. +- `BranchEffect.arms: NonSingletonList` — a branch with 0 or 1 arms is meaningless (0: no workflow; 1: just the arm itself, use the arm's `body` directly). Type rejects both. +- `LoopEffect.body: WorkflowEffect` — singleton (one body), never empty. Type is non-optional. +- `ParallelEffect.branches: NonSingletonList` — 0 branches is empty parallelism; 1 branch is just the branch itself, no concurrency. Type rejects both. ✓ cardinality invariants at the type level; no "lowering guarantees" prose required. + +**Q2 — Index/handle types.** Does a raw `Int` or `NodeId` encode something with a domain restriction? + +- `BranchArm.condition: PortId` is a typed substrate handle. The existing `PortId` carrier already witnesses "this is a substrate port." Construction validity ("the port is Bool-typed at the condition slot") is the `branch_arm_of(port, body) -> Option` responsibility, matching the `param_of` pattern from Track 9. ✓ no raw Int/NodeId with comment-level validity. + +**Q3 — Duplicated fact.** Does Field A duplicate what's derivable from Field B? + +- `WorkflowEffect` variants do not duplicate any fact carried by `OperationEffect`, `EffectShape`, or `CompositionVerdict`. `WorkflowEffect` is the *input structure* above ops; the ops themselves are authored once and referenced by `LinearEffect.ops`. `CompositionVerdict` is the *output* of the algebra — DB-18 does not pre-compute a verdict on the workflow shape. ✓ no duplication. + +**Q5 — Construction authority.** Are multiple call sites independently constructing the same fact? + +- Single authority: lowering is the sole producer of `WorkflowEffect` values (Part 2 wires this). The lens and downstream consumers are pure readers. No parallel construction path through multiple lowering sites. `branch_arm_of` is the sole `BranchArm` constructor; no raw `BranchArm { ... }` literal should appear outside that constructor. ✓ single authority. + +**Q6 — Representation duality.** Can the same fact be expressed in two structurally different shapes that comparison treats differently? + +- A workflow that is structurally a sequence with a nested branch is expressed *uniquely* as the outermost variant's shape. "Linear with branch in the middle" cannot be represented as `LinearEffect` (because `LinearEffect.ops` is `NonEmptyList`, not `NonEmptyList`); it must be lifted to `BranchEffect { arms: [LinearEffect{A∪B∪D}, LinearEffect{A∪C∪D}] }` — i.e., a branch between two linear paths. This is a structurally-unique canonical form, not a choice between two equivalent representations. ✓ no representation duality. + +All six audit questions stamp cleanly. The full six-question audit is recorded in this section for reviewer-bot verification rather than re-derivation at PR-review time. + +### Consumer contract — Stage 2b (LinearEffect-only scope) + +Stage 2b's `analyze_workflow` lens walks a `WorkflowEffect` value, dispatching per variant. Only `LinearEffect` yields a `CompositionVerdict`; the other three variants produce explicit diagnostics. Pseudocode shape (not the Part 2 implementation): + +```dag +fn analyze_workflow(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport { + match workflow { + LinearEffect { ops } => { + // Delegate to the post-PR-#529 algebra. `compose_effects` + // returns `CompositionVerdict` directly. + let verdict = compose_effects(ops |> to_list) + WorkflowIdempotencyReport { + verdict: verdict, + diagnostic: match verdict { + IdempotentComposition => None + BrokenBy { first_breaker } => + Some(Diagnostic { + kind: WorkflowNonIdempotent { breaker: first_breaker } + // ... span + fix info + }) + } + } + } + BranchEffect => + // Stage 2d owns branch composition (max-path / lattice meet + // over arms). Stage 2b emits a diagnostic that names the + // downstream consumer so the user (and CI) can tell the + // difference between "this is broken" and "this isn't scope". + report_unsupported_variant( + variant_name: "BranchEffect", + downstream_stage: "Lane 2 Stage 2d (branch-wise composition — symbolic cost)", + reason: "branch-wise effect composition is not modeled at Stage 2b" + ) + LoopEffect => + report_unsupported_variant( + variant_name: "LoopEffect", + downstream_stage: "Lane 2 Stage 2d (fixpoint bound + body convergence)", + reason: "iterated effect composition requires body idempotency + bound evidence" + ) + ParallelEffect => + report_unsupported_variant( + variant_name: "ParallelEffect", + downstream_stage: "Lane 2 Stage 2e (parallelism-as-lens + commutativity witness)", + reason: "concurrent effect composition requires algebraic commutativity evidence" + ) + } +} +``` + +`WorkflowIdempotencyReport`'s exact shape after DB-18 + PR #529 reconcile is left to Part 2 design — the current `{ idempotent: Bool, breaking_op: String?, evidence_chain: List, diagnostic: Diagnostic? }` shape in `lane2-compile-time-proofs.md` §Stage 2b is a pre-#529 placeholder and must be replaced with a structural carrier projecting through `CompositionVerdict` (per PR #529's pre-start-gate update; see §"Open questions" below for the exact reconciliation). + +**Why the other three variants are diagnostics, not silent skips.** Per C-8 (INVARIANTS §Diagnostic severity): every detectable condition is either an error or not a diagnostic. A workflow with a `BranchEffect` root is not an error — it is an error only if Stage 2b were claiming to be exhaustive over all workflow shapes. Stage 2b explicitly is *not* exhaustive; it is linear-only. So the diagnostic is informational-framed-as-error ("this variant is out of Stage 2b's scope; see Stage 2d") rather than "your workflow is broken." Downstream stages (2d, 2e) will subsume these diagnostics when they ship, replacing them with real verdicts. The diagnostic contract is: *name the variant, name the downstream stage that will consume it, name the reason it cannot be verdicted at Stage 2b.* + +**Why `LinearEffect` is the initial consumer scope.** `compose_effects(effects: List) -> CompositionVerdict` is already the algebra for linear composition. Stage 2b wires the lens to that algebra without extending it. The other three variants each demand an algebra extension (meet-over-arms, fixpoint, commutative-concurrent) that belongs to downstream stages. Gating Stage 2b to `LinearEffect` delivers the thesis claim ("idempotency is inescapable at compile time") for the 80% case without pre-committing to algebra extensions the downstream stages will design. + +### Coexistence with `CompositionVerdict` (post-PR #529) + +`WorkflowEffect` and `CompositionVerdict` are orthogonal axes of the effect algebra. Concretely: + +| Carrier | Axis | Produced by | Consumed by | +|---|---|---|---| +| `WorkflowEffect` | Input structure — "what shape is this workflow?" | Lowering (Part 2) | Stage 2b lens, Stage 2d cost lens, Stage 2e parallelism lens, Stage 2f user-dimension lens | +| `OperationEffect` | Per-op effect shape | Lowering (Stage 2a) via `derive_op_effect` | `compose_effects`, `LinearEffect.ops` | +| `CompositionVerdict` | Output verdict — "what does this linear chain compose to?" | `compose_effects(List)` (post-PR #529) | Stage 2b lens report; any downstream diagnostic constructor | + +`WorkflowEffect` never carries a `CompositionVerdict`; `CompositionVerdict` never carries a `WorkflowEffect`. The edge between them is a single lens-level match arm: `LinearEffect { ops } =>` projects `ops` through `compose_effects` to obtain `CompositionVerdict`. No enclosing record pairs them. + +This is deliberate. PR #529's R3 removed `ComposedEffect { operations, verdict }` because pairing the input walk and the output verdict in a single record admitted correlated-fields incoherence. DB-18 preserves that lesson: the input structure lives on one axis (`WorkflowEffect`), the output verdict lives on another axis (`CompositionVerdict`), and nothing pairs them. Stage 2b callers that want "the walk AND the verdict" keep their own `WorkflowEffect` in scope and read the lens's `CompositionVerdict` output — two facts kept separate at their natural sites, with no correlation for the type system to enforce. + +### What DB-18 does NOT touch + +- `compose_effects` signature or body. Continues to consume `List` and return `CompositionVerdict` per PR #529. +- `EffectShape` partitioning (`IdempotentShape` / `BreakingShape`). Per PR #529, already landed in Stage 2a follow-up. +- `OperationEffect`, `BreakingOperation`, `CompositionVerdict`, `IdempotencyEvidence`, `ModifierCheck`. Untouched. +- The computation substrate (`Behavior`, `LoopBound`, `Cluster`). Untouched. `Behavior` stays at five variants. +- The reflected `Dag` record. Untouched. No new sidecar table. +- `WorkflowEffectConcern` (existing record at `src/v3/std/effects.dag:565–569`). This is a diagnostic-construction helper, not an input carrier; remains as-is. Part 2 may or may not project through it for the `LinearEffect` diagnostic construction. +- V2 `dsl/std/effects.dag`. V3-only, per the same scope discipline PR #529 applied. + +--- + +## Worked example — workflow shapes under DB-18 + +Three example workflows, their `WorkflowEffect` encoding, and the Stage 2b verdict under this design. + +**Workflow A — GCP linear chain (Stage 2b golden path).** + +``` +Secret.upsert(key=secret_id, value=...) // UpsertEffect +STS.exchange(token=...) // ReadEffect (idempotent on state) +IAM.grant(role=...) // UpsertEffect +``` + +Encoded as: + +``` +WorkflowEffect::LinearEffect { + ops: NonEmptyList { + first: OperationEffect { operation_name: "Secret.upsert", shape: IsIdempotent(UpsertEffect{..}) } + rest: [ + OperationEffect { operation_name: "STS.exchange", shape: IsIdempotent(ReadEffect) } + OperationEffect { operation_name: "IAM.grant", shape: IsIdempotent(UpsertEffect{..}) } + ] + } +} +``` + +Stage 2b lens dispatches on `LinearEffect`, calls `compose_effects(ops)`, receives `CompositionVerdict::IdempotentComposition`. Report green. ✓ — mirrors the Stage 2b fixture from `lane2-compile-time-proofs.md` §Stage 2b Acceptance. + +**Workflow B — linear chain with terminal audit log (Stage 2b red).** + +``` +Secret.upsert(...) // UpsertEffect (idempotent) +STS.exchange(...) // ReadEffect (idempotent) +IAM.grant(...) // UpsertEffect (idempotent) +AuditLog.append(event=...) // AppendEffect (breaking) +``` + +Encoded as `LinearEffect { ops: [upsert, exchange, grant, append] }`. Stage 2b calls `compose_effects`, receives `CompositionVerdict::BrokenBy { first_breaker: BreakingOperation { operation_name: "AuditLog.append", shape: IsBreaking(AppendEffect) } }`. Report red with diagnostic naming `AuditLog.append` as the breaker. ✓ — mirrors `lane2-compile-time-proofs.md` §Stage 2b Acceptance fixture 2. + +**Workflow C — retry with nested branch (Stage 2b diagnostic).** + +``` +LoopEffect { + body: BranchEffect { + arms: [ + BranchArm { condition: , body: LinearEffect{[Secret.get]} } + BranchArm { condition: , body: LinearEffect{[STS.exchange, Secret.get]} } + ] + } +} +``` + +Stage 2b dispatches on `LoopEffect`, emits diagnostic: *"`LoopEffect` encountered at workflow root; iterated effect composition requires body idempotency + bound evidence — consumed by Lane 2 Stage 2d (fixpoint bound + body convergence)."* No verdict. ✓ — the diagnostic path from §"Consumer contract" above; the user knows *why* Stage 2b did not verdict and *which stage will*. + +--- + +## Acceptance + +Part 1 (this design doc, no code) is accepted when the design locks: + +1. `WorkflowEffect` is a four-variant coproduct (`LinearEffect | BranchEffect | LoopEffect | ParallelEffect`) with exactly the payload shapes in §"Substrate changes." +2. `BranchArm { condition: PortId, body: WorkflowEffect }` is the sole structural distinction between `BranchEffect` and `ParallelEffect` payloads at Part 1 scope. +3. Q1–Q6 substrate-principle audit is stamped in-doc; Q4 dissolution receipt is stamped in-doc. +4. `LinearEffect` is the Stage 2b consumer; the other three variants produce `Diagnostic` via `report_unsupported_variant` with the downstream stage name. +5. `CompositionVerdict` and `WorkflowEffect` coexist on orthogonal axes — no enclosing record pairs them; `LinearEffect`'s dispatch is the sole edge between them. +6. Part 2 pre-start gate (this section below) names PR #529 as the merge precondition. + +Part 2 (follow-up implementation PR) must satisfy: + +1. `src/v3/std/effects.dag` declares `type WorkflowEffect` and `type BranchArm` per the substrate-changes section. +2. Rust mirror in `src/v3/compiler/src/dag.rs` (or equivalent sidecar file) declares `WorkflowEffect` and `BranchArm` as `enum` and `struct`; reflection-invariant test (`m2_field_access_binding_test.rs`-style) locks the two sides together. +3. `src/v3/lenses/idempotency.dag` (new) declares `analyze_workflow(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport` and dispatches per variant. `LinearEffect` calls `compose_effects`; the other three emit `Diagnostic` via `report_unsupported_variant`. +4. `WorkflowIdempotencyReport` replaces the pre-#529 `idempotent: Bool + breaking_op: String?` draft shape with a structural carrier projecting through `CompositionVerdict` (per PR #529's pre-start-gate update to `lane2-compile-time-proofs.md` §Stage 2b). Exact shape resolved at Part 2 design (see Open question §2). +5. Stage 2b acceptance fixtures from `lane2-compile-time-proofs.md` §Stage 2b (three fixtures: GCP linear green, terminal AppendEffect red, POST-create keyless failure) pass against the lens-over-`WorkflowEffect` implementation. +6. New fixtures exercising the diagnostic paths: one `BranchEffect` workflow, one `LoopEffect` workflow, one `ParallelEffect` workflow — each producing the documented diagnostic with the named downstream stage. +7. `src/v3/ROADMAP.md` Lane 2 Stage 2b row updates to `✅ Shipped (DB-18 + lens)` with links to both design docs. + +--- + +## Pre-start gate for Part 2 implementation + +**PR #529 (ComposedEffect reshape, session `warm-newt-750`, branch `session/warm-newt-750`) MUST land before Part 2 coding begins.** + +Reason: DB-18's `LinearEffect` consumer delegates to `compose_effects(List) -> CompositionVerdict`. That signature is defined by PR #529's R3. Starting Part 2 against the pre-#529 `compose_effects(...) -> ComposedEffect` signature would lock DB-18 against a shape being removed — the lens would either (a) build against the doomed `ComposedEffect` and need immediate rework, or (b) anticipate the R3 shape and diverge from main until #529 lands. Both paths burn throughput for no signal. Wait for #529's merge; then Part 2 proceeds against a stable `compose_effects` signature. + +**STOP-AND-ESCALATE rule for Part 2 dispatch:** + +If Part 2 implementation discovers any of the following, HALT and report to director chat rather than patching forward: + +- `WorkflowEffect`'s 4-variant shape is insufficient — e.g., a real workflow fixture requires a fifth variant or a variant payload reshape. DB-18 locks the shape; reshape is a DB revision, not an in-flight patch. +- `BranchArm`'s `condition: PortId` does not distinguish BranchEffect from ParallelEffect structurally in practice (Q4 receipt regresses). Same rule — reshape the substrate only through a DB revision. +- Stage 2b's `analyze_workflow` signature needs to return something other than `WorkflowIdempotencyReport` (e.g., `List` for multi-diagnostic workflows). This is an API refinement, possibly an open question for director-chat clearance; do not silently change the signature. +- The reflected `Dag` needs a `workflows` sidecar after all (i.e., `WorkflowEffect` turns out to span multiple declarations). This is an architectural finding worth a DB revision, not a silent additional field. + +The director chat owns the call on each of these. Silent in-flight patches destroy the structural-finding signal the pre-clearance exists to capture (per `phase-plan-2026-04-18.md` §7 "Structural-finding escalation rule"). + +--- + +## Open questions + +1. **Does `ParallelEffect` need a commutativity witness field in Part 2?** The Pattern 3 algebraic-form analysis argues `ParallelEffect` is a concurrent-product (⊗) requiring algebraic commutativity on the target state. Part 1 does NOT require the witness in the carrier because Stage 2b emits a diagnostic on `ParallelEffect` (no verdict computed). When Stage 2e's parallelism lens binds as a real consumer, it will either (a) extend `ParallelEffect` with `commutativity: AlgebraRef` (or a named-alternative witness carrier) as an additive payload extension, or (b) prove that commutativity is derivable from the workflow's op-level algebra without a substrate field. Decision deferred to Stage 2e design pre-clearance — NOT patched forward in Part 2 implementation. + +2. **Exact post-#529 `WorkflowIdempotencyReport` shape.** The pre-#529 draft shape in `lane2-compile-time-proofs.md` §Stage 2b is `{ idempotent: Bool, breaking_op: String?, evidence_chain: List, diagnostic: Diagnostic? }`. PR #529's pre-start-gate update says this must be replaced with a structural carrier projecting through `CompositionVerdict`. Candidate post-DB-18 shape: `{ verdict: CompositionVerdict, workflow_shape: WorkflowEffect, diagnostic: Diagnostic? }` — but this pairs the algebra output with the input structure in a single record, which is exactly the correlated-fields pattern PR #529 R3 rejected. Alternative: return `CompositionVerdict` OR `Diagnostic` directly from `analyze_workflow` (no outer record); the lens's caller pairs it with the `WorkflowEffect` they already hold. Resolve at Part 2 design. + +3. **Does `LoopEffect.body` need a bound carrier in Part 1?** Substrate has `LoopBound = Cardinality { count: PortId } | Descent { cluster: ClusterId }` post-DB-9 R2.1. Stage 2d (symbolic cost) will almost certainly need the bound to compute recursion depth. Part 1 does NOT include a bound field because Stage 2b emits a diagnostic on `LoopEffect` without reading bound info. Part 2 / Stage 2d can add `bound: LoopBound` as an additive extension when its consumer binds — same rationale as §1. If the director chat prefers a single shape-lock including the bound, this PR can land with `LoopEffect { body: WorkflowEffect, bound: LoopBound }` from the start; decision is a scope judgment, not a correctness one. + +4. **Does `LinearEffect.ops` need to be `NonEmptyList` instead of `NonEmptyList` to allow mixed nesting?** The worked example C demonstrates the answer: a linear sequence with a branch in the middle lifts to a `BranchEffect` of two linear paths (distributive over composition). No `LinearEffect` with a `WorkflowEffect` in the middle is needed; the structural canonical form is always a variant at the outermost shape. Locked per Q6 (no representation duality) — not open. + +5. **Does the substrate need a `WorkflowDeclaration` record to host `WorkflowEffect`?** Today, `OperationDeclaration` hosts `OperationEffect`-related fields (method, path, etc.). There is no equivalent `WorkflowDeclaration` for multi-op workflows. Part 1 does NOT propose one because Stage 2b can read `WorkflowEffect` values from any declaration slot the user happens to declare them on. Part 2 may or may not introduce a `WorkflowDeclaration` depending on user-range fixture shape; this is an ergonomics question, not a correctness one. Open for Part 2 design. + +--- + +## Rejected alternatives + +**R0 — lens-only, derive `WorkflowEffect` from DAG shape at walk time.** Rejected in §Revision history. Core issue: re-derives workflow structure heuristically from Bind chain / Branch / Loop behavior, violating `feedback_lenses_not_passes` ("heuristic = missing physics"). A workflow's control-flow shape is a fact about the author's intent, not something the compiler should re-infer per-lens. + +**R-alt-A — single `WorkflowEffect` coproduct with a kind tag + optional payload fields.** Shape: `WorkflowEffect { kind: Linear | Branch | Loop | Parallel, ops: Option>, arms: Option>, body: Option }`. Rejected because it admits illegal state combinations (`kind: Linear` with `arms: Some(...)`) — the classic `Bool + Option` pattern `feedback_state_space_vs_behavioral_invariants` names. The four-variant coproduct is the state-space-sound alternative. + +**R-alt-B — hierarchical inheritance: `WorkflowEffect` is-a `List` with optional control-flow overlay.** Rejected because it pre-commits to linear-as-base and overlays branches as a non-structural layer. Stage 2d's branch-wise composition would have to peel back the overlay — the overlay IS the structure. Better to encode control-flow structurally from the start (R1). + +**R-alt-C — merge `BranchEffect` and `ParallelEffect` into `ChoiceEffect { kind: Exclusive | Concurrent, arms: NonSingletonList }`.** Rejected because it re-introduces the `kind + discriminator` pattern that Q4's dissolution receipt is supposed to eliminate. The whole point of the four-variant coproduct is that each variant traces to a distinct categorical operation; compressing two of them under a flag re-creates the compression the coproduct solves. + +**R-alt-D — drop `ParallelEffect`; add it later via DB-N when Stage 2e ships.** Viable; the user's directive explicitly asked for 4 variants, so this is not the chosen path. If the director chat later prefers a 3-variant initial shape, that is an additive refinement (drop `ParallelEffect`; Stage 2e's DB adds it when it ships) and does not regress the Q4 receipt for the remaining three variants. Noted for director-chat discussion if the 4-variant commit proves too heavy for Part 1. + +--- + +## Cross-references + +- `feedback_coproduct_dissolution` — Q4 receipt (§"Dissolution receipt" above cites patterns 1–4). +- `feedback_substrate_principle_audit` — Q1–Q6 (§"Substrate principle audit" cites all six). +- `feedback_state_space_vs_behavioral_invariants` — cardinality invariants via `NonEmptyList` / `NonSingletonList`; rejected alternative R-alt-A. +- `feedback_lenses_not_passes` — rejected alternative R0 (lens-level re-derivation). +- `feedback_no_metadata_markers` — `BranchArm.condition: PortId` is a typed handle, not a string marker. +- `feedback_fail_closed_discipline` — `report_unsupported_variant` produces a `Diagnostic`, not a silent skip. +- DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. +- DB-16 (`design-db16-refined-generic-substitution.md`) — worked example of Part 1 design / Part 2 impl split; mirrors DB-18's scope discipline. +- PR #529 (`design-composed-effect-reshape.md`) — `CompositionVerdict` authority that DB-18's `LinearEffect` path delegates to. +- ROADMAP (`../ROADMAP.md` and `../src/v3/ROADMAP.md`) — Lane 2 Stage 2b entry; this DB advances the row from planned lens-only to substrate-carrier + lens per director escalation. + +--- + +End of DB-18 Part 1. diff --git a/docs/lane2-compile-time-proofs.md b/docs/lane2-compile-time-proofs.md index 6252133a19f..08d941c944f 100644 --- a/docs/lane2-compile-time-proofs.md +++ b/docs/lane2-compile-time-proofs.md @@ -87,6 +87,22 @@ Lens reads each operation's declared `idempotent` modifier AND derives from path **Escalation:** if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension. +**Input structure (DB-18) — `WorkflowEffect` substrate carrier.** The escalation clause above named the extension that DB-18 now locks in: workflow control-flow shape is promoted from "something the lens reconstructs from DAG shape at walk time" (a heuristic, which `feedback_lenses_not_passes` rejects) to a first-class substrate carrier `WorkflowEffect` that lowering produces and lenses read structurally. Shape: + +``` +type WorkflowEffect + = LinearEffect { ops: NonEmptyList } + | BranchEffect { arms: NonSingletonList } + | LoopEffect { body: WorkflowEffect } + | ParallelEffect { branches: NonSingletonList } + +type BranchArm { condition: PortId; body: WorkflowEffect } +``` + +Stage 2b is the initial consumer with **`LinearEffect`-only scope**: the lens matches on `LinearEffect` and delegates to `compose_effects(ops |> to_list) -> CompositionVerdict` (post-PR #529). The other three variants emit explicit fail-closed diagnostics (C-8) naming the downstream stage that will consume them — `BranchEffect` → Stage 2d branch-wise composition, `LoopEffect` → Stage 2d fixpoint / body convergence, `ParallelEffect` → Stage 2e parallelism-as-lens with commutativity witness. No silent skip; a `WorkflowEffect` the Stage 2b lens cannot verdict produces a `Diagnostic` explaining which variant, which downstream stage, and why. The `analyze_workflow` signature above becomes `fn analyze_workflow(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport`; the NodeId-indexed form in the draft above is the pre-DB-18 sketch. `WorkflowEffect` and `CompositionVerdict` (post-PR #529) coexist on orthogonal axes — `WorkflowEffect` is the input-structure carrier, `CompositionVerdict` is the output-verdict carrier; no enclosing record pairs them (preserving the lesson PR #529 R3 locked). + +Design: [design-db18-workflow-effect-carrier.md](./design-db18-workflow-effect-carrier.md) — Part 1 locks the 4-variant shape, the `BranchArm` structural distinction, the Q4 dissolution receipt, and the Q1–Q6 substrate-principle audit. **Part 2 implementation is gated on PR #529 landing** (see Pre-start gate below); Part 2 ships `type WorkflowEffect` in `src/v3/std/effects.dag`, the Rust mirror in `src/v3/compiler/src/dag.rs`, the lens in `src/v3/lenses/idempotency.dag`, and reconciles `WorkflowIdempotencyReport` with `CompositionVerdict` (see DB-18 §Open questions). + **Pre-start gate:** Stage 2b does not start consuming `ComposedEffect` as if it were stable substrate. The Stage 2a `DerivedOpEffect` collapse has landed (derivation now emits `OperationEffect`, which is the same shape `compose_effects` walks). The remaining Stage 2a follow-up is `ComposedEffect`: it must stop encoding workflow verdicts as duplicated summary fields before Stage 2b consumers depend on it. ### Stage 2c — Test obligation materialization (M) From abbff110dbb95e7d7c32e818808ff6431ad2007c Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:00:12 -0400 Subject: [PATCH 02/29] Merge main into session/bright-bat-435 Resolves conflict in docs/lane2-compile-time-proofs.md after PR #529 (ComposedEffect reshape) landed on main. Kept the DB-18 insertion in place, superseded the pre-#529 Pre-start gate text with #529's "cleared" version, and kept #529's new Stage 2b / Track 17a design-axis paragraph. Updated the DB-18 "Design:" line to reflect that the Part 2 gate is now cleared (was forward-looking at write time). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/lane2-compile-time-proofs.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/lane2-compile-time-proofs.md b/docs/lane2-compile-time-proofs.md index bd5497d06cb..727129d5d57 100644 --- a/docs/lane2-compile-time-proofs.md +++ b/docs/lane2-compile-time-proofs.md @@ -102,7 +102,6 @@ Lens reads each operation's declared `idempotent` modifier AND derives from path **Escalation:** if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension. -<<<<<<< HEAD **Input structure (DB-18) — `WorkflowEffect` substrate carrier.** The escalation clause above named the extension that DB-18 now locks in: workflow control-flow shape is promoted from "something the lens reconstructs from DAG shape at walk time" (a heuristic, which `feedback_lenses_not_passes` rejects) to a first-class substrate carrier `WorkflowEffect` that lowering produces and lenses read structurally. Shape: ``` @@ -117,14 +116,11 @@ type BranchArm { condition: PortId; body: WorkflowEffect } Stage 2b is the initial consumer with **`LinearEffect`-only scope**: the lens matches on `LinearEffect` and delegates to `compose_effects(ops |> to_list) -> CompositionVerdict` (post-PR #529). The other three variants emit explicit fail-closed diagnostics (C-8) naming the downstream stage that will consume them — `BranchEffect` → Stage 2d branch-wise composition, `LoopEffect` → Stage 2d fixpoint / body convergence, `ParallelEffect` → Stage 2e parallelism-as-lens with commutativity witness. No silent skip; a `WorkflowEffect` the Stage 2b lens cannot verdict produces a `Diagnostic` explaining which variant, which downstream stage, and why. The `analyze_workflow` signature above becomes `fn analyze_workflow(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport`; the NodeId-indexed form in the draft above is the pre-DB-18 sketch. `WorkflowEffect` and `CompositionVerdict` (post-PR #529) coexist on orthogonal axes — `WorkflowEffect` is the input-structure carrier, `CompositionVerdict` is the output-verdict carrier; no enclosing record pairs them (preserving the lesson PR #529 R3 locked). -Design: [design-db18-workflow-effect-carrier.md](./design-db18-workflow-effect-carrier.md) — Part 1 locks the 4-variant shape, the `BranchArm` structural distinction, the Q4 dissolution receipt, and the Q1–Q6 substrate-principle audit. **Part 2 implementation is gated on PR #529 landing** (see Pre-start gate below); Part 2 ships `type WorkflowEffect` in `src/v3/std/effects.dag`, the Rust mirror in `src/v3/compiler/src/dag.rs`, the lens in `src/v3/lenses/idempotency.dag`, and reconciles `WorkflowIdempotencyReport` with `CompositionVerdict` (see DB-18 §Open questions). +Design: [design-db18-workflow-effect-carrier.md](./design-db18-workflow-effect-carrier.md) — Part 1 locks the 4-variant shape, the `BranchArm` structural distinction, the Q4 dissolution receipt, and the Q1–Q6 substrate-principle audit. **Part 2 implementation was gated on PR #529 (ComposedEffect reshape) landing — gate now cleared** (see Pre-start gate below, which #529 updated to "cleared"). Part 2 ships `type WorkflowEffect` in `src/v3/std/effects.dag`, the Rust mirror in `src/v3/compiler/src/dag.rs`, the lens in `src/v3/lenses/idempotency.dag`, and reconciles `WorkflowIdempotencyReport` with `CompositionVerdict` (see DB-18 §Open questions). -**Pre-start gate:** Stage 2b does not start consuming `ComposedEffect` as if it were stable substrate. The Stage 2a `DerivedOpEffect` collapse has landed (derivation now emits `OperationEffect`, which is the same shape `compose_effects` walks). The remaining Stage 2a follow-up is `ComposedEffect`: it must stop encoding workflow verdicts as duplicated summary fields before Stage 2b consumers depend on it. -======= **Pre-start gate:** cleared. All Stage 2a follow-ups have landed — `DerivedOpEffect` collapsed into `OperationEffect` (PR #521); `EffectShape` partitioned into `IsIdempotent(IdempotentShape) | IsBreaking(BreakingShape)`; `BrokenBy`'s payload narrowed to `BreakingOperation { shape: BreakingShape }`; `ComposedEffect` removed. `compose_effects(effects: List) -> CompositionVerdict` is the algebra's output. Stage 2b consumes `CompositionVerdict` directly; callers pair it with their own input `List` for diagnostic rendering. The Stage 2b `WorkflowIdempotencyReport` shape above is one layer higher (a lens report with diagnostic), not a duplication of the algebra verdict — when Stage 2b implements it, the report must match on `CompositionVerdict` and project through `BrokenBy.first_breaker.shape: BreakingShape`. The `breaking_op: String?` field in the draft `WorkflowIdempotencyReport` shape above is a design placeholder from before the Stage 2a partition landed; Stage 2b's implementer should replace it with a structural carrier derived from `CompositionVerdict` rather than copy the flat shape. The implementer must not reintroduce a `ComposedEffect`-style record that pairs `CompositionVerdict` with a sibling list field, since that reintroduces the correlated-fields incoherence R3 just removed. **Stage 2b / Track 17a design axis — witness vs copy.** `BreakingOperation` today is a *copy* of the originating `OperationEffect`, not a carrier-relative witness into `compose_effects`'s input list. This is a deliberate Track 9 deferral: `ElementRef`-style handles "land when a concrete consumer needs it, not speculatively" (`ROADMAP.md:763-772`). When the Stage 2b lens or a Track 17a consumer first needs to render the breaker's position in the workflow or structurally tie the verdict back to its evidence chain, the handle graduation belongs in that PR — not retrofitted into Stage 2a. Until that graduation, consumers that need position-in-workflow can search the caller's input list by name; the verdict itself is sound (sum variants coherent, `BreakingShape` narrowing intact). ->>>>>>> origin/main ### Stage 2c — Test obligation materialization (M) From 46544abd1c30cffb6eef1f3f134d8939ff64c2c2 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:02:12 -0400 Subject: [PATCH 03/29] =?UTF-8?q?docs:=20DB-18=20=E2=80=94=20update=20Work?= =?UTF-8?q?flowEffectConcern=20line-range=20citation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-PR #529 the file grew from ~569 to 673 lines (partition types, CompositionVerdict, new fns); WorkflowEffectConcern shifted from :565-569 to :669-673. Cite both the new and old ranges so the historical anchor is preserved. Addresses the single callout in the claude-review on #531. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design-db18-workflow-effect-carrier.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 27e6ec09245..4329d0a9f9b 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -233,7 +233,7 @@ This is deliberate. PR #529's R3 removed `ComposedEffect { operations, verdict } - `OperationEffect`, `BreakingOperation`, `CompositionVerdict`, `IdempotencyEvidence`, `ModifierCheck`. Untouched. - The computation substrate (`Behavior`, `LoopBound`, `Cluster`). Untouched. `Behavior` stays at five variants. - The reflected `Dag` record. Untouched. No new sidecar table. -- `WorkflowEffectConcern` (existing record at `src/v3/std/effects.dag:565–569`). This is a diagnostic-construction helper, not an input carrier; remains as-is. Part 2 may or may not project through it for the `LinearEffect` diagnostic construction. +- `WorkflowEffectConcern` (existing record at `src/v3/std/effects.dag:669–673` post-PR #529; was `:565–569` pre-#529). This is a diagnostic-construction helper, not an input carrier; remains as-is. Part 2 may or may not project through it for the `LinearEffect` diagnostic construction. - V2 `dsl/std/effects.dag`. V3-only, per the same scope discipline PR #529 applied. --- From 1e79315457bf9871cbc93e3f9ae251ee1725cd98 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:13:52 -0400 Subject: [PATCH 04/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 4329d0a9f9b..95c8de72d95 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -4,8 +4,8 @@ **Design blocker:** DB-18 (new substrate carrier `WorkflowEffect` — a four-variant coproduct describing workflow control-flow shape for effect-algebra composition) **Consumers:** Lane 2 Stage 2b workflow idempotency lens (initial consumer; `LinearEffect`-only scope). Downstream: Stage 2d symbolic cost, Stage 2e parallelism-as-lens, Stage 2f user-declared dimensions. -**Status:** Proposed. **Part 1 design-only — implementation and tests ship in the follow-up Part 2 PR.** -**Companion:** [design-composed-effect-reshape.md](./design-composed-effect-reshape.md) (PR #529) reshapes the *output* side of the effect algebra: `ComposedEffect` is removed; `compose_effects(effects: List) -> CompositionVerdict` is the post-reshape algebra. DB-18 adds the *input* side: a typed carrier describing the workflow's control-flow structure above `List`. The two live on orthogonal axes — `WorkflowEffect` is the input structure the lens walks; `CompositionVerdict` is the output the algebra returns. They coexist; neither replaces the other. +**Status:** R2 — responding to PR #531 ChatGPT review's three blocking concerns on fail-closed construction, type-level witness for the BranchEffect/ParallelEffect structural distinction, and LinearEffect carrier coverage of the monoidal identity. Part 1 design-only — implementation and tests ship in the follow-up Part 2 PR. +**Companion:** [design-composed-effect-reshape.md](./design-composed-effect-reshape.md) (PR #529, landed 2026-04-18 as `8c7e7acdd`) reshapes the *output* side of the effect algebra: `ComposedEffect` is removed; `compose_effects(effects: List) -> CompositionVerdict` is the post-reshape algebra. DB-18 adds the *input* side: a typed carrier describing the workflow's control-flow structure above `List`. The two live on orthogonal axes — `WorkflowEffect` is the input structure the lens walks; `CompositionVerdict` is the output the algebra returns. They coexist; neither replaces the other. --- @@ -22,7 +22,8 @@ DB-18 is that legitimate design extension, moved up-front rather than deferred. ## Revision history - **R0** (rejected; pre-dispatch): "Stage 2b is lens-only — model workflows as `List` produced by the lens at walk time from the DAG's Bind chain." Rejected because it re-derives workflow structure heuristically from DAG shape at lens time, violating `feedback_lenses_not_passes` ("heuristic = missing physics"). A workflow's control-flow shape is an input fact to the algebra, not something to reconstruct. -- **R1** (current, proposed): Workflow control-flow shape is lifted into the substrate as a first-class carrier `WorkflowEffect`. Stage 2b's lens reads the carrier structurally and dispatches per variant. Four variants (`LinearEffect | BranchEffect | LoopEffect | ParallelEffect`) cover the four workflow-level control-flow topologies; recursion through `WorkflowEffect` fields composes them. `LinearEffect` is the only variant Stage 2b consumes; the other three emit diagnostic + name the downstream stage. +- **R1** (superseded by R2): Workflow control-flow shape lifted into the substrate as a first-class four-variant carrier `WorkflowEffect`; `BranchArm { condition: PortId, body: WorkflowEffect }` as the Pattern-2 distinguisher between `BranchEffect` and `ParallelEffect`; `LinearEffect { ops: NonEmptyList }`. ChatGPT review (2026-04-18) flagged three blocking structural concerns: (a) `branch_arm_of(...) -> Option` weakens fail-closed — a non-Bool condition port is a user-reachable modeling error, not a benign "absent" case; (b) raw `PortId` admits the wrong port kind unless every producer obeys convention, keeping the BranchEffect/ParallelEffect distinction behavioral rather than type-level (directly regresses the Q4 receipt it claims to carry); (c) excluding the empty word from `LinearEffect.ops` creates asymmetry with `compose_effects(List)` — the monoidal identity is a real workflow shape (no-op paths, empty branch arms) with no honest representation. +- **R2** (current): Response to the three R1 blocking items. (a+b) Introduce `BoolPortRef` as a Track 9-style typed opaque handle whose sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` fails closed at construction — a non-Bool port returns `None` and the lowering caller emits a `Diagnostic` per C-8. `BranchArm.condition` is typed `BoolPortRef` (not raw `PortId`), so non-Bool conditions are unrepresentable at the type level; raw-literal construction of `BranchArm` cannot produce an invalid arm because `BoolPortRef` has no unsafe constructor. The Q4 Pattern-2 dissolution receipt is now carried by the *typed witness* rather than by constructor convention. (c) Relax `LinearEffect.ops` from `NonEmptyList` to `List`. Empty `LinearEffect` is the monoidal identity — `compose_effects([])` already returns `IdempotentComposition` (the identity verdict); rejecting it would make the carrier narrower than the algebra. This is also the canonical form for no-op paths (e.g., a branch arm whose body is "do nothing"). --- From bb08594f336566981c684dbf62d3da4ad716e1a5 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:15:44 -0400 Subject: [PATCH 05/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 72 ++++++++++++++++----- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 95c8de72d95..4cc5d201fea 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -63,34 +63,74 @@ DB-18 is that legitimate design extension, moved up-front rather than deferred. // // Recursive composition: BranchEffect, LoopEffect, and ParallelEffect // each carry sub-WorkflowEffect fields, so arbitrary nesting composes. -// LinearEffect terminates the recursion at a non-empty list of +// LinearEffect terminates the recursion at a (possibly empty) list of // OperationEffect values — the same carrier compose_effects consumes -// post-PR-#529. +// post-PR-#529. The empty case is the monoidal identity. type WorkflowEffect - = LinearEffect { ops: NonEmptyList } + = LinearEffect { ops: List } | BranchEffect { arms: NonSingletonList } | LoopEffect { body: WorkflowEffect } | ParallelEffect { branches: NonSingletonList } -// 🟢 TERMINAL. A single arm of a BranchEffect — the condition port -// witnessing "this arm is taken" plus the workflow executed when the -// condition holds. Separating condition from workflow makes the -// payload structurally distinct from ParallelEffect's -// NonSingletonList, so the Q4 dissolution receipt -// passes at the payload level rather than requiring a separate -// discriminator tag. +// 🟢 TERMINAL. A single arm of a BranchEffect — the Bool-typed +// condition port witnessing "this arm is taken" plus the workflow +// executed when the condition holds. Separating condition from +// workflow makes the payload structurally distinct from +// ParallelEffect's NonSingletonList, so the Q4 +// dissolution receipt passes at the payload level rather than +// requiring a separate discriminator tag. // -// `condition: PortId` — a typed substrate handle into the computation -// DAG. The port is the producer of the Bool-typed value the branch -// dispatches on. Constructor-validated: `branch_arm_of(port, body)` -// returns `None` for non-Bool-typed ports, so a malformed arm is -// unrepresentable. +// `condition: BoolPortRef` — a typed opaque handle statically +// witnessing that the referenced port is Bool-typed. Because +// BoolPortRef has no unsafe constructor (see `type BoolPortRef` +// below), a raw-literal `BranchArm { condition: , ... }` +// is not representable: the field type itself rejects non-Bool ports +// at the type level. No convention-level constructor discipline is +// required. type BranchArm { - condition: PortId + condition: BoolPortRef body: WorkflowEffect } ``` +**Add `BoolPortRef` as a Track 9-style substrate integrity primitive** (`src/v3/std/substrate.dag` — alongside `NonEmptyList`, `NonSingletonList`, `ParamRef`, `TransformRef`; graduates WITH the first consumer per Track 9 discipline): + +```dag +// 🟢 TERMINAL. Typed opaque handle statically witnessing that the +// referenced substrate port's declared type is Bool. The sole +// constructor `bool_port_of` validates the port's type at +// construction time; there is no unsafe escape hatch for producing +// a BoolPortRef around a non-Bool port. +// +// Track 9 graduation rationale: this is the third Track 9 primitive +// (after ParamRef and TransformRef from DB-9 R2.1). It graduates +// here because BranchArm's first consumer (DB-18 §BranchArm) needs +// a type-level witness for the Bool-typed-port invariant that +// carries the Q4 dissolution receipt distinguishing BranchEffect +// from ParallelEffect. A raw PortId would push the invariant into +// constructor convention, which is API-level rather than type-level +// enforcement — exactly the pattern Track 9 exists to dissolve. +// +// Second consumer candidate: computation-substrate Branch behavior's +// condition slot. If a Track 9 graduation review determines the +// invariant belongs there too, BoolPortRef extends rather than +// duplicating — same primitive, two consumers. Not a Part 1 commit. +type BoolPortRef + +// Sole constructor. Returns None when the port's declared type is +// not Bool; callers that need to emit a Diagnostic on None do so +// themselves (fail-closed discipline at the caller boundary, not at +// the primitive boundary — same pattern as `Dag::param_of`). +fn bool_port_of(dag: Dag, port: PortId) -> BoolPortRef? + +// Accessor. Recover the underlying PortId from the typed handle; +// the witness that the port is Bool-typed is carried by the handle, +// not by a duplicate inspection. +fn port_of(bool_ref: BoolPortRef) -> PortId +``` + +**On the fail-closed story for `bool_port_of` returning `Option`.** ChatGPT review flagged that `branch_arm_of(port, body) -> Option` weakens fail-closed because a non-Bool condition port is a user-reachable modeling error. R2's response inverts the concern: `bool_port_of` returning `Option` is the Track 9 primitive's internal-validation contract (matching `param_of` from DB-9 R2.1). The primitive does not itself emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. But the *lowering path that calls `bool_port_of`* is required to handle `None` by emitting a `Diagnostic::BranchConditionNotBool { port, actual_type, span }` per C-8, never by silently absorbing the absence. The primitive's Option is internal validation; the caller's Diagnostic emission is the fail-closed boundary. This is the same pattern Track 9 established for `param_of` (DB-9 R2.1): the primitive returns Option; the lowering site that consumes it is responsible for the Diagnostic. Part 2's Acceptance item names this explicitly as a required behavior on the lowering path — a silent `None` absorption in Part 2 implementation is a Part 2 review gate. + **Why this belongs in `std/effects.dag` rather than `std/substrate.dag`.** `WorkflowEffect` is a concept of the effect algebra — it describes input shapes that `compose_effects` (and its downstream peers) dispatch over. The substrate's own declarations (`Dag`, `Declaration`, `Behavior`, `LoopBound`, `Cluster`) describe the *computation and type substrate*; the effect algebra is a lens-writable layer above that substrate. The existing file already hosts `OperationEffect`, `EffectShape`, `IdempotencyEvidence`, `CompositionVerdict` — all effect-algebra types. `WorkflowEffect` joins that family. **No reflected `type Dag` changes.** `WorkflowEffect` values live inside user code (as fields of workflow declarations) and inside lens output, not as a sidecar table on `Dag`. The authority for "what workflow does this call site model?" is the user's declaration; the lens walks declarations and reads their `WorkflowEffect`-typed fields. No new `Dag.workflows: List<...>` field is needed (cf. DB-9 R2.1 `Dag.clusters` sidecar which is necessary because clusters span multiple declarations; a `WorkflowEffect` is a single declaration-local value). From 30cf1e0aee970e91c8ba0bee7cfe2b5fb7ef5cab Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:17:20 -0400 Subject: [PATCH 06/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 4cc5d201fea..4181e2295f4 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -147,14 +147,14 @@ Per `feedback_coproduct_dissolution`, every new coproduct must pass the four-pat | Variant A | Variant B | Payload A | Payload B | Distinct? | |---|---|---|---|---| -| `LinearEffect` | `BranchEffect` | `NonEmptyList` | `NonSingletonList` | ✓ different element types + different cardinality contract | -| `LinearEffect` | `LoopEffect` | `NonEmptyList` | `WorkflowEffect` | ✓ list vs single | -| `LinearEffect` | `ParallelEffect` | `NonEmptyList` | `NonSingletonList` | ✓ different element types + different cardinality contract | +| `LinearEffect` | `BranchEffect` | `List` | `NonSingletonList` | ✓ different element types + different cardinality contract | +| `LinearEffect` | `LoopEffect` | `List` | `WorkflowEffect` | ✓ list vs single | +| `LinearEffect` | `ParallelEffect` | `List` | `NonSingletonList` | ✓ different element types + different cardinality contract | | `BranchEffect` | `LoopEffect` | `NonSingletonList` | `WorkflowEffect` | ✓ list vs single | -| `BranchEffect` | `ParallelEffect` | `NonSingletonList` | `NonSingletonList` | ✓ `BranchArm` ≠ `WorkflowEffect` (BranchArm carries `condition: PortId` that ParallelEffect branches do not) | +| `BranchEffect` | `ParallelEffect` | `NonSingletonList` | `NonSingletonList` | ✓ `BranchArm` ≠ `WorkflowEffect` (BranchArm carries `condition: BoolPortRef` that ParallelEffect branches do not; the typed-witness field is unrepresentable in ParallelEffect's element type) | | `LoopEffect` | `ParallelEffect` | `WorkflowEffect` | `NonSingletonList` | ✓ single vs list | -The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonList<...>` at the outer shape and recurse on `WorkflowEffect` at the element level. The distinction is carried by `BranchArm { condition: PortId, body: WorkflowEffect }` versus a plain `WorkflowEffect`: a branch arm is an arm-gated-by-condition, a parallel branch is an unconditioned concurrent workflow. The `condition: PortId` is the load-bearing structural fact — it encodes "which arm is taken is a function of this specific DAG port's value," which is irreducibly different from "all branches execute concurrently and converge at a join." Pattern 2's dissolution criterion ("same shape, different label") does not apply because the BranchArm wrapper carries a structurally required field ParallelEffect's branches do not. ✓ does not apply. +The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonList<...>` at the outer shape and recurse on `WorkflowEffect` at the element level. The distinction is carried by `BranchArm { condition: BoolPortRef, body: WorkflowEffect }` versus a plain `WorkflowEffect`: a branch arm is an arm-gated-by-a-Bool-typed-port, a parallel branch is an unconditioned concurrent workflow. `condition: BoolPortRef` is the load-bearing structural fact — and after R2 it is carried by a **typed opaque handle**, not a raw `PortId`. `BoolPortRef`'s sole constructor `bool_port_of(dag, port)` returns `None` for non-Bool ports, and has no unsafe escape hatch, so the "port is Bool-typed" invariant is witnessed by the type itself rather than by constructor convention. A raw-literal `BranchArm { condition: , body: ... }` is not type-checkable; only `BranchArm { condition: , body: ... }` is, and a `BoolPortRef` can only come from the fail-closed constructor. Pattern 2's dissolution criterion ("same shape, different label") does not apply because the `BranchArm` wrapper carries a structurally required *typed* field `ParallelEffect`'s branches do not, and the type-level witness closes the convention-level hole the R1 draft left open. ✓ does not apply. **Pattern 3 — Algebraic-form (traces to intro/elim of algebraic structures).** The four variants trace to four distinct categorical operations on effect algebras: @@ -175,14 +175,14 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q1 — Cardinality invariants.** Does any variant admit `[]` where invariant says ≥1, or singletons where ≥2? -- `LinearEffect.ops: NonEmptyList` — a linear workflow with zero ops is meaningless (nothing to compose). Type rejects `[]`. +- `LinearEffect.ops: List` — empty is explicitly allowed as the **monoidal identity** (the unit element of the free monoid on `OperationEffect`). `compose_effects([])` already returns `IdempotentComposition`; the type accepts empty for carrier/algebra symmetry. This is also the honest representation of a no-op workflow (e.g., a branch arm whose body does nothing). R1's `NonEmptyList` rejection of empty was a state-space error — the R2 relaxation fixes it. No ≥1 invariant applies here; the invariant is "a list of ops (zero or more)," matching the algebra. - `BranchEffect.arms: NonSingletonList` — a branch with 0 or 1 arms is meaningless (0: no workflow; 1: just the arm itself, use the arm's `body` directly). Type rejects both. - `LoopEffect.body: WorkflowEffect` — singleton (one body), never empty. Type is non-optional. -- `ParallelEffect.branches: NonSingletonList` — 0 branches is empty parallelism; 1 branch is just the branch itself, no concurrency. Type rejects both. ✓ cardinality invariants at the type level; no "lowering guarantees" prose required. +- `ParallelEffect.branches: NonSingletonList` — 0 branches is empty parallelism; 1 branch is just the branch itself, no concurrency. Type rejects both. ✓ cardinality invariants at the type level (including the correctly-permissive empty `LinearEffect` case); no "lowering guarantees" prose required. **Q2 — Index/handle types.** Does a raw `Int` or `NodeId` encode something with a domain restriction? -- `BranchArm.condition: PortId` is a typed substrate handle. The existing `PortId` carrier already witnesses "this is a substrate port." Construction validity ("the port is Bool-typed at the condition slot") is the `branch_arm_of(port, body) -> Option` responsibility, matching the `param_of` pattern from Track 9. ✓ no raw Int/NodeId with comment-level validity. +- `BranchArm.condition: BoolPortRef` is a typed opaque handle (Track 9-style primitive, R2 graduation). The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` validates the port's declared type is Bool; there is no unsafe escape hatch. A raw `PortId` does not typecheck in `BranchArm.condition`; the field type is `BoolPortRef`, not `PortId`. Matching the `ParamRef` / `TransformRef` pattern from DB-9 R2.1 — the handle carries both "is a valid port reference" AND "is Bool-typed," with the relation folded into the type rather than carried by constructor discipline. ✓ no raw `Int`/`NodeId`/`PortId` with comment-level or constructor-level validity; the invariant is carried by the type itself. **Q3 — Duplicated fact.** Does Field A duplicate what's derivable from Field B? @@ -190,7 +190,8 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q5 — Construction authority.** Are multiple call sites independently constructing the same fact? -- Single authority: lowering is the sole producer of `WorkflowEffect` values (Part 2 wires this). The lens and downstream consumers are pure readers. No parallel construction path through multiple lowering sites. `branch_arm_of` is the sole `BranchArm` constructor; no raw `BranchArm { ... }` literal should appear outside that constructor. ✓ single authority. +- **Authority site for `WorkflowEffect` values (locked, per R2 blocking finding).** See §"Authority site for WorkflowEffect" below — `WorkflowEffect` values live exclusively on user-declared `data` declarations whose declared type is structurally `WorkflowEffect`. Exactly one `WorkflowEffect` per qualifying `Declaration`; no sidecar table, no parallel hosting. Lowering is the sole producer; lens and downstream consumers are pure readers. +- **Construction authority for `BranchArm`.** `BranchArm` itself is directly constructible (it is a plain record), but its validity is witnessed by the `condition: BoolPortRef` field type, NOT by a constructor. The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` is the Track 9 primitive; any lowering path that obtains a `BoolPortRef` did so via `bool_port_of` and therefore either (a) emitted a `Diagnostic::BranchConditionNotBool` on `None` or (b) proceeded with a type-level Bool witness. Direct `BranchArm { condition: bool_port_ref, body: ... }` construction is fine by design because the condition field CANNOT carry an invalid port. Raw-literal `BranchArm { condition: , ... }` is a type error. ✓ single authority on the typed-witness construction path; no convention-level escape hatch. **Q6 — Representation duality.** Can the same fact be expressed in two structurally different shapes that comparison treats differently? From 408cf02c9e0805a6d2931e39abb949d8da4680a1 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:19:11 -0400 Subject: [PATCH 07/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 95 +++++++++++++++++---- 1 file changed, 77 insertions(+), 18 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 4181e2295f4..49cb4f91e07 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -195,21 +195,65 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q6 — Representation duality.** Can the same fact be expressed in two structurally different shapes that comparison treats differently? -- A workflow that is structurally a sequence with a nested branch is expressed *uniquely* as the outermost variant's shape. "Linear with branch in the middle" cannot be represented as `LinearEffect` (because `LinearEffect.ops` is `NonEmptyList`, not `NonEmptyList`); it must be lifted to `BranchEffect { arms: [LinearEffect{A∪B∪D}, LinearEffect{A∪C∪D}] }` — i.e., a branch between two linear paths. This is a structurally-unique canonical form, not a choice between two equivalent representations. ✓ no representation duality. +- A workflow that is structurally a sequence with a nested branch is expressed *uniquely* as the outermost variant's shape. "Linear with branch in the middle" cannot be represented as `LinearEffect` (because `LinearEffect.ops` is `List`, not `List`); it must be lifted to `BranchEffect { arms: [LinearEffect{A∪B∪D}, LinearEffect{A∪C∪D}] }` — i.e., a branch between two linear paths. This is a structurally-unique canonical form, not a choice between two equivalent representations. A no-op workflow has exactly one canonical form — `LinearEffect { ops: [] }` (the R2 relaxation) — and cannot be spelled another way under the 4-variant coproduct. ✓ no representation duality. All six audit questions stamp cleanly. The full six-question audit is recorded in this section for reviewer-bot verification rather than re-derivation at PR-review time. +### Authority site for `WorkflowEffect` + +`WorkflowEffect` values are authored in exactly one place: a user-declared `data` declaration whose declared type is structurally `WorkflowEffect`. The v3 `data` declaration form (`data : = `) — shipped in `3a.2` per ROADMAP — is the surface the user writes; lowering parses the RHS literal into a `WorkflowEffect` value and stores it on the resulting `Declaration`'s value slot. + +**Authority contract (locked, Part 1):** + +- **One `WorkflowEffect` per qualifying `Declaration`.** A `Declaration` whose declared type structurally matches `WorkflowEffect` carries exactly one `WorkflowEffect` value on its value slot. No sidecar table (`Dag.workflows`), no parallel hosting, no multi-workflow-per-declaration. +- **No other hosting site.** `WorkflowEffect` values do NOT appear as fields of `OperationDeclaration`, `ServiceDeclaration`, or any other declaration kind. They do NOT appear as nested fields inside `OperationEffect` or `CompositionVerdict` (the orthogonality guarantee from §"Coexistence"). They do NOT appear in `Dag.nodes` as behavior-level payloads. The only authoritative site is the value slot of a user-declared `data` declaration whose type is `WorkflowEffect`. +- **Lens discovery.** `analyze_workflow` and downstream consumers enumerate qualifying declarations: + ```dag + fn find_workflow_declarations(d: Dag) -> List + // Returns the ID of every Declaration whose declared type is + // structurally WorkflowEffect. Structural match (not name-based) + // so aliased declared types are recovered via the standard + // type-resolution walk. + ``` + and read the value via a standard data-declaration value accessor (exact accessor depends on v3's current data-declaration shape; Part 2 binds against the substrate as it stands at Part 2 implementation time). +- **Q5 closure.** This is the Q5 single-authority answer: the authority site is the `data` declaration; lowering is the sole producer of `WorkflowEffect` values (by parsing the RHS literal); every reader walks the same discovery function. No alternative hosting paths, no inference-time re-derivation, no lens-time reconstruction. + +**Why `data` rather than a new `WorkflowDeclaration` kind.** Both are valid; R2 picks `data` because it is zero-substrate-expansion. The v3 substrate already hosts `data` declarations — adding `WorkflowEffect` as a declarable type in that slot is pure consumer extension of an existing surface. A new `WorkflowDeclaration` kind would be a substrate addition that duplicates what `data` already provides (name + type + value) with a specific-to-workflow type constraint; the specific-to-workflow constraint is already carried by the *type* `WorkflowEffect`. Per `feedback_std_over_patterns` ("types-in-std/ dissolves CX patterns; don't enumerate special cases"), the right move is to reuse the generic `data` surface and let the type annotation carry the specificity. + +**Why not a `Dag.workflows` sidecar.** Sidecar tables are justified when a fact spans multiple declarations (e.g., DB-9 R2.1's `Dag.clusters` — an SCC member list spans the cluster's member declarations, so a per-declaration field cannot host it). A `WorkflowEffect` is a single-declaration-local value — it describes one workflow, authored on one declaration. A sidecar would be a parallel cache of facts already stored on the declaration; the consumer could drift from the authority. Per Q3 (no duplicated facts) and Q5 (single authority), the data-declaration value slot is the correct place. + +**Rejected host alternatives (for the record):** + +- **`WorkflowDeclaration` new kind.** Extra substrate concept, no capability gain over `data`. Rejected. +- **Field on `OperationDeclaration`.** Conflates per-op effect (the operation's own effect shape) with workflow-level composition (how ops compose). They are different kinds of fact. Rejected. +- **Sidecar table `Dag.workflows`.** Parallel to the declaration; would require a derivation to keep it in sync. Rejected (see above). +- **Lens-time reconstruction from DAG shape.** The R0 rejection. WorkflowEffect is authored, not derived. + +This section closes the R1 Open Question §5 (which read "Does the substrate need a `WorkflowDeclaration` record to host WorkflowEffect? ... Open for Part 2 design"). R2 answers: no new kind needed; `data` is the host; the answer is locked in Part 1, not deferred. + ### Consumer contract — Stage 2b (LinearEffect-only scope) Stage 2b's `analyze_workflow` lens walks a `WorkflowEffect` value, dispatching per variant. Only `LinearEffect` yields a `CompositionVerdict`; the other three variants produce explicit diagnostics. Pseudocode shape (not the Part 2 implementation): ```dag -fn analyze_workflow(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport { +// Caller resolves `workflow_decl` to a WorkflowEffect value via the +// authority-site accessor (§"Authority site for WorkflowEffect"), +// then dispatches per variant. Both entry points coexist: the +// DeclarationId form is natural for "walk the DAG and analyze all +// workflow-typed data declarations"; the direct-WorkflowEffect form +// is natural for unit tests and sub-workflow recursion. +fn analyze_workflow(d: Dag, workflow_decl: DeclarationId) -> WorkflowIdempotencyReport { + let workflow = read_workflow_value(d, workflow_decl) // standard data-value accessor + analyze_workflow_value(d, workflow) +} + +fn analyze_workflow_value(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport { match workflow { LinearEffect { ops } => { // Delegate to the post-PR-#529 algebra. `compose_effects` - // returns `CompositionVerdict` directly. - let verdict = compose_effects(ops |> to_list) + // returns `CompositionVerdict` directly; for ops = [] it + // returns IdempotentComposition (the monoidal identity). + let verdict = compose_effects(ops) WorkflowIdempotencyReport { verdict: verdict, diagnostic: match verdict { @@ -322,11 +366,22 @@ Encoded as `LinearEffect { ops: [upsert, exchange, grant, append] }`. Stage 2b c **Workflow C — retry with nested branch (Stage 2b diagnostic).** ``` +// port_auth_ok and port_auth_failed are PortId values authored by +// lowering. Each is wrapped via bool_port_of(dag, port) before +// landing in BranchArm.condition; a non-Bool port would surface a +// Diagnostic::BranchConditionNotBool on the lowering path and +// prevent BranchArm construction. LoopEffect { body: BranchEffect { arms: [ - BranchArm { condition: , body: LinearEffect{[Secret.get]} } - BranchArm { condition: , body: LinearEffect{[STS.exchange, Secret.get]} } + BranchArm { + condition: bool_port_of(d, port_auth_ok).unwrap() // Bool witness obtained + body: LinearEffect { ops: [Secret.get] } + } + BranchArm { + condition: bool_port_of(d, port_auth_failed).unwrap() + body: LinearEffect { ops: [STS.exchange, Secret.get] } + } ] } } @@ -340,22 +395,26 @@ Stage 2b dispatches on `LoopEffect`, emits diagnostic: *"`LoopEffect` encountere Part 1 (this design doc, no code) is accepted when the design locks: -1. `WorkflowEffect` is a four-variant coproduct (`LinearEffect | BranchEffect | LoopEffect | ParallelEffect`) with exactly the payload shapes in §"Substrate changes." -2. `BranchArm { condition: PortId, body: WorkflowEffect }` is the sole structural distinction between `BranchEffect` and `ParallelEffect` payloads at Part 1 scope. -3. Q1–Q6 substrate-principle audit is stamped in-doc; Q4 dissolution receipt is stamped in-doc. -4. `LinearEffect` is the Stage 2b consumer; the other three variants produce `Diagnostic` via `report_unsupported_variant` with the downstream stage name. +1. `WorkflowEffect` is a four-variant coproduct (`LinearEffect | BranchEffect | LoopEffect | ParallelEffect`) with the payload shapes in §"Substrate changes." Payload shapes are locked for Part 1 scope; additive-extension fields graduate from §Open questions as downstream stages bind (e.g., `LoopEffect.bound` when Stage 2d consumes, `ParallelEffect.commutativity` when Stage 2e consumes). The Part 1 lock is the set of VARIANTS (four, exactly) and the MANDATORY fields of each variant (listed in §"Substrate changes"); optional additive fields added by later stages do not regress this lock. +2. `BranchArm { condition: BoolPortRef, body: WorkflowEffect }` is the sole structural distinction between `BranchEffect` and `ParallelEffect` payloads. `BoolPortRef` is a Track 9-style typed opaque handle whose sole constructor is `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` — raw-literal construction of a `BranchArm` around a non-Bool port is not representable at the type level. The Q4 Pattern-2 distinction is carried by the typed witness, not by constructor discipline. +3. Q1–Q6 substrate-principle audit is stamped in-doc; Q4 dissolution receipt is stamped in-doc. Q5 single-authority is resolved by the §"Authority site for WorkflowEffect" section (not deferred). +4. `LinearEffect` is the Stage 2b consumer; the other three variants produce `Diagnostic` via `report_unsupported_variant` with the downstream stage name. Empty `LinearEffect.ops` is the monoidal identity, consumed by `compose_effects([])` = `IdempotentComposition`. 5. `CompositionVerdict` and `WorkflowEffect` coexist on orthogonal axes — no enclosing record pairs them; `LinearEffect`'s dispatch is the sole edge between them. -6. Part 2 pre-start gate (this section below) names PR #529 as the merge precondition. +6. `WorkflowEffect`'s authority site is locked to user-declared `data` declarations typed `WorkflowEffect` (§"Authority site for WorkflowEffect"). No sidecar table; no parallel hosting; no deferred host-selection decision. +7. Part 2 pre-start gate: PR #529 merged 2026-04-18 (`8c7e7acdd`), clearing the gate. Part 2 dispatch is structurally unblocked. Part 2 (follow-up implementation PR) must satisfy: -1. `src/v3/std/effects.dag` declares `type WorkflowEffect` and `type BranchArm` per the substrate-changes section. -2. Rust mirror in `src/v3/compiler/src/dag.rs` (or equivalent sidecar file) declares `WorkflowEffect` and `BranchArm` as `enum` and `struct`; reflection-invariant test (`m2_field_access_binding_test.rs`-style) locks the two sides together. -3. `src/v3/lenses/idempotency.dag` (new) declares `analyze_workflow(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport` and dispatches per variant. `LinearEffect` calls `compose_effects`; the other three emit `Diagnostic` via `report_unsupported_variant`. -4. `WorkflowIdempotencyReport` replaces the pre-#529 `idempotent: Bool + breaking_op: String?` draft shape with a structural carrier projecting through `CompositionVerdict` (per PR #529's pre-start-gate update to `lane2-compile-time-proofs.md` §Stage 2b). Exact shape resolved at Part 2 design (see Open question §2). -5. Stage 2b acceptance fixtures from `lane2-compile-time-proofs.md` §Stage 2b (three fixtures: GCP linear green, terminal AppendEffect red, POST-create keyless failure) pass against the lens-over-`WorkflowEffect` implementation. -6. New fixtures exercising the diagnostic paths: one `BranchEffect` workflow, one `LoopEffect` workflow, one `ParallelEffect` workflow — each producing the documented diagnostic with the named downstream stage. -7. `src/v3/ROADMAP.md` Lane 2 Stage 2b row updates to `✅ Shipped (DB-18 + lens)` with links to both design docs. +1. `src/v3/std/effects.dag` declares `type WorkflowEffect` and `type BranchArm` per the substrate-changes section (with `LinearEffect.ops: List`, `BranchArm.condition: BoolPortRef`). +2. `src/v3/std/substrate.dag` (or equivalent Track 9 primitive site) declares `type BoolPortRef` + `fn bool_port_of(dag, port) -> BoolPortRef?` + `fn port_of(bool_ref) -> PortId` per §"Add `BoolPortRef` as a Track 9-style substrate integrity primitive." Primitive graduation trigger: DB-18 BranchArm is the first consumer. +3. Rust mirror in `src/v3/compiler/src/dag.rs` (or equivalent sidecar file) declares `WorkflowEffect`, `BranchArm`, and `BoolPortRef` as `enum` and `struct`s; reflection-invariant test (`m2_field_access_binding_test.rs`-style) locks the two sides together. +4. **Fail-closed on `bool_port_of` returning `None`.** Every lowering site that calls `bool_port_of` must emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` on `None` and must NOT construct a `BranchArm` with that port via any alternative path. Silent `None` absorption is a Part 2 review gate — reject any PR that pattern-matches `bool_port_of(...)` result without a Diagnostic emission branch. +5. `src/v3/lenses/idempotency.dag` (new) declares `analyze_workflow(d: Dag, workflow_decl: DeclarationId) -> WorkflowIdempotencyReport` (resolving the declaration's value via the data-declaration accessor) and `analyze_workflow_value(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport`, dispatching per variant. `LinearEffect` calls `compose_effects`; the other three emit `Diagnostic` via `report_unsupported_variant`. +6. `WorkflowIdempotencyReport` replaces the pre-#529 `idempotent: Bool + breaking_op: String?` draft shape with a structural carrier projecting through `CompositionVerdict` (per PR #529's pre-start-gate update to `lane2-compile-time-proofs.md` §Stage 2b). Exact shape resolved at Part 2 design (see Open question §2). +7. Stage 2b acceptance fixtures from `lane2-compile-time-proofs.md` §Stage 2b (three fixtures: GCP linear green, terminal AppendEffect red, POST-create keyless failure) pass against the lens-over-`WorkflowEffect` implementation. +8. New fixtures exercising the diagnostic paths: one `BranchEffect` workflow, one `LoopEffect` workflow, one `ParallelEffect` workflow — each producing the documented diagnostic with the named downstream stage. Additionally: one fixture exercising the fail-closed path on `bool_port_of` — a malformed `data` declaration attempting to use a non-Bool port as a branch condition must surface `Diagnostic::BranchConditionNotBool`, not silently construct an invalid `BranchArm`. +9. New fixture exercising the empty-`LinearEffect` case: a `LinearEffect { ops: [] }` workflow produces `CompositionVerdict::IdempotentComposition` (the monoidal identity). +10. `src/v3/ROADMAP.md` Lane 2 Stage 2b row updates to `✅ Shipped (DB-18 + lens)` with links to both design docs. --- From 163da432d85636a5dcf3e465411d1f299eaf11f3 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:20:44 -0400 Subject: [PATCH 08/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 27 +++++++++++++-------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 49cb4f91e07..256198bf239 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -420,18 +420,19 @@ Part 2 (follow-up implementation PR) must satisfy: ## Pre-start gate for Part 2 implementation -**PR #529 (ComposedEffect reshape, session `warm-newt-750`, branch `session/warm-newt-750`) MUST land before Part 2 coding begins.** +**Gate status: CLEARED.** PR #529 (ComposedEffect reshape, session `warm-newt-750`) merged 2026-04-18 as commit `8c7e7acdd`. Part 2 dispatch is structurally unblocked. -Reason: DB-18's `LinearEffect` consumer delegates to `compose_effects(List) -> CompositionVerdict`. That signature is defined by PR #529's R3. Starting Part 2 against the pre-#529 `compose_effects(...) -> ComposedEffect` signature would lock DB-18 against a shape being removed — the lens would either (a) build against the doomed `ComposedEffect` and need immediate rework, or (b) anticipate the R3 shape and diverge from main until #529 lands. Both paths burn throughput for no signal. Wait for #529's merge; then Part 2 proceeds against a stable `compose_effects` signature. +**Original gate rationale (preserved for record):** DB-18's `LinearEffect` consumer delegates to `compose_effects(List) -> CompositionVerdict`. That signature is defined by PR #529's R3. Starting Part 2 against the pre-#529 `compose_effects(...) -> ComposedEffect` signature would have locked DB-18 against a shape being removed — the lens would either (a) build against the doomed `ComposedEffect` and need immediate rework, or (b) anticipate the R3 shape and diverge from main until #529 landed. Both paths burned throughput for no signal. The gate prevented that divergence; #529's merge cleared it. **STOP-AND-ESCALATE rule for Part 2 dispatch:** If Part 2 implementation discovers any of the following, HALT and report to director chat rather than patching forward: - `WorkflowEffect`'s 4-variant shape is insufficient — e.g., a real workflow fixture requires a fifth variant or a variant payload reshape. DB-18 locks the shape; reshape is a DB revision, not an in-flight patch. -- `BranchArm`'s `condition: PortId` does not distinguish BranchEffect from ParallelEffect structurally in practice (Q4 receipt regresses). Same rule — reshape the substrate only through a DB revision. -- Stage 2b's `analyze_workflow` signature needs to return something other than `WorkflowIdempotencyReport` (e.g., `List` for multi-diagnostic workflows). This is an API refinement, possibly an open question for director-chat clearance; do not silently change the signature. -- The reflected `Dag` needs a `workflows` sidecar after all (i.e., `WorkflowEffect` turns out to span multiple declarations). This is an architectural finding worth a DB revision, not a silent additional field. +- `BoolPortRef` does not distinguish BranchEffect from ParallelEffect structurally in practice (Q4 receipt regresses), OR the `bool_port_of` fail-closed semantics require an escape hatch for some legitimate lowering case. Same rule — reshape the substrate only through a DB revision. +- Stage 2b's `analyze_workflow` / `analyze_workflow_value` signature needs to return something other than `WorkflowIdempotencyReport` (e.g., `List` for multi-diagnostic workflows). This is an API refinement, possibly an open question for director-chat clearance; do not silently change the signature. +- The authority-site decision (user-declared `data` typed `WorkflowEffect`) turns out to be insufficient — e.g., a real fixture needs WorkflowEffect hosted somewhere `data` cannot reach. DB-18 locks the authority site; changing it is a DB revision, not an in-flight patch. +- `bool_port_of` returning `None` is silently absorbed at any lowering site (no `Diagnostic::BranchConditionNotBool` emission). This is a C-8 violation — Part 2 review must reject it. The director chat owns the call on each of these. Silent in-flight patches destroy the structural-finding signal the pre-clearance exists to capture (per `phase-plan-2026-04-18.md` §7 "Structural-finding escalation rule"). @@ -439,15 +440,15 @@ The director chat owns the call on each of these. Silent in-flight patches destr ## Open questions -1. **Does `ParallelEffect` need a commutativity witness field in Part 2?** The Pattern 3 algebraic-form analysis argues `ParallelEffect` is a concurrent-product (⊗) requiring algebraic commutativity on the target state. Part 1 does NOT require the witness in the carrier because Stage 2b emits a diagnostic on `ParallelEffect` (no verdict computed). When Stage 2e's parallelism lens binds as a real consumer, it will either (a) extend `ParallelEffect` with `commutativity: AlgebraRef` (or a named-alternative witness carrier) as an additive payload extension, or (b) prove that commutativity is derivable from the workflow's op-level algebra without a substrate field. Decision deferred to Stage 2e design pre-clearance — NOT patched forward in Part 2 implementation. +1. **Does `ParallelEffect` need a commutativity witness field in Part 2?** The Pattern 3 algebraic-form analysis argues `ParallelEffect` is a concurrent-product (⊗) requiring algebraic commutativity on the target state. Part 1 does NOT require the witness in the carrier because Stage 2b emits a diagnostic on `ParallelEffect` (no verdict computed). When Stage 2e's parallelism lens binds as a real consumer, it will either (a) extend `ParallelEffect` with `commutativity: AlgebraRef` (or a named-alternative witness carrier) as an additive payload extension, or (b) prove that commutativity is derivable from the workflow's op-level algebra without a substrate field. Decision deferred to Stage 2e design pre-clearance — NOT patched forward in Part 2 implementation. This is an additive extension consistent with the Acceptance-item-1 language (locked VARIANTS + MANDATORY fields; optional additive fields are in-scope for later stages). -2. **Exact post-#529 `WorkflowIdempotencyReport` shape.** The pre-#529 draft shape in `lane2-compile-time-proofs.md` §Stage 2b is `{ idempotent: Bool, breaking_op: String?, evidence_chain: List, diagnostic: Diagnostic? }`. PR #529's pre-start-gate update says this must be replaced with a structural carrier projecting through `CompositionVerdict`. Candidate post-DB-18 shape: `{ verdict: CompositionVerdict, workflow_shape: WorkflowEffect, diagnostic: Diagnostic? }` — but this pairs the algebra output with the input structure in a single record, which is exactly the correlated-fields pattern PR #529 R3 rejected. Alternative: return `CompositionVerdict` OR `Diagnostic` directly from `analyze_workflow` (no outer record); the lens's caller pairs it with the `WorkflowEffect` they already hold. Resolve at Part 2 design. +2. **Exact post-#529 `WorkflowIdempotencyReport` shape.** The pre-#529 draft shape in `lane2-compile-time-proofs.md` §Stage 2b is `{ idempotent: Bool, breaking_op: String?, evidence_chain: List, diagnostic: Diagnostic? }`. PR #529's pre-start-gate update says this must be replaced with a structural carrier projecting through `CompositionVerdict`. Candidate post-DB-18 shape: `{ verdict: CompositionVerdict, workflow_shape: WorkflowEffect, diagnostic: Diagnostic? }` — but this pairs the algebra output with the input structure in a single record, which is exactly the correlated-fields pattern PR #529 R3 rejected. Alternative: return `CompositionVerdict` OR `Diagnostic` directly from `analyze_workflow_value` (no outer record); the lens's caller pairs it with the `WorkflowEffect` they already hold. The R1 claude-review (2026-04-18) leans to the alternative (no outer record); R2 inherits that lean. Resolve at Part 2 design. -3. **Does `LoopEffect.body` need a bound carrier in Part 1?** Substrate has `LoopBound = Cardinality { count: PortId } | Descent { cluster: ClusterId }` post-DB-9 R2.1. Stage 2d (symbolic cost) will almost certainly need the bound to compute recursion depth. Part 1 does NOT include a bound field because Stage 2b emits a diagnostic on `LoopEffect` without reading bound info. Part 2 / Stage 2d can add `bound: LoopBound` as an additive extension when its consumer binds — same rationale as §1. If the director chat prefers a single shape-lock including the bound, this PR can land with `LoopEffect { body: WorkflowEffect, bound: LoopBound }` from the start; decision is a scope judgment, not a correctness one. +3. **Does `LoopEffect.body` need a bound carrier in Part 1?** Substrate has `LoopBound = Cardinality { count: PortId } | Descent { cluster: ClusterId }` post-DB-9 R2.1. Stage 2d (symbolic cost) will almost certainly need the bound to compute recursion depth. Part 1 does NOT include a bound field because Stage 2b emits a diagnostic on `LoopEffect` without reading bound info. Part 2 / Stage 2d can add `bound: LoopBound` as an additive extension when its consumer binds — consistent with Acceptance item 1's explicit additive-extension clause (which was tightened in R2 to prevent the doc-coherence gap ChatGPT flagged). If the director chat prefers a single shape-lock including the bound, a R3 revision can add `LoopEffect { body: WorkflowEffect, bound: LoopBound }`; decision is a scope judgment, not a correctness one. -4. **Does `LinearEffect.ops` need to be `NonEmptyList` instead of `NonEmptyList` to allow mixed nesting?** The worked example C demonstrates the answer: a linear sequence with a branch in the middle lifts to a `BranchEffect` of two linear paths (distributive over composition). No `LinearEffect` with a `WorkflowEffect` in the middle is needed; the structural canonical form is always a variant at the outermost shape. Locked per Q6 (no representation duality) — not open. +4. **Does `LinearEffect.ops` need to be `List` instead of `List` to allow mixed nesting?** The worked example C demonstrates the answer: a linear sequence with a branch in the middle lifts to a `BranchEffect` of two linear paths (distributive over composition). No `LinearEffect` with a `WorkflowEffect` in the middle is needed; the structural canonical form is always a variant at the outermost shape. Locked per Q6 (no representation duality) — not open. -5. **Does the substrate need a `WorkflowDeclaration` record to host `WorkflowEffect`?** Today, `OperationDeclaration` hosts `OperationEffect`-related fields (method, path, etc.). There is no equivalent `WorkflowDeclaration` for multi-op workflows. Part 1 does NOT propose one because Stage 2b can read `WorkflowEffect` values from any declaration slot the user happens to declare them on. Part 2 may or may not introduce a `WorkflowDeclaration` depending on user-range fixture shape; this is an ergonomics question, not a correctness one. Open for Part 2 design. +5. ~~**Does the substrate need a `WorkflowDeclaration` record to host `WorkflowEffect`?**~~ **CLOSED in R2.** `WorkflowEffect` values live exclusively on user-declared `data` declarations typed `WorkflowEffect`. See §"Authority site for `WorkflowEffect`" above. No new declaration kind needed; `data` is the host. This closes the Q5 single-authority question at Part 1 scope rather than deferring to Part 2. --- @@ -463,6 +464,12 @@ The director chat owns the call on each of these. Silent in-flight patches destr **R-alt-D — drop `ParallelEffect`; add it later via DB-N when Stage 2e ships.** Viable; the user's directive explicitly asked for 4 variants, so this is not the chosen path. If the director chat later prefers a 3-variant initial shape, that is an additive refinement (drop `ParallelEffect`; Stage 2e's DB adds it when it ships) and does not regress the Q4 receipt for the remaining three variants. Noted for director-chat discussion if the 4-variant commit proves too heavy for Part 1. +**R-alt-E — raw `PortId` on `BranchArm.condition` with constructor-level validation only (the R1 shape).** Rejected in R2 after the PR #531 ChatGPT + codex reviews. The raw-`PortId` shape relies on `branch_arm_of(port, body) -> Option` to validate "port is Bool-typed" at construction, and requires every lowering site to obey the constructor-only-no-raw-literals convention. That is API-level enforcement: a contributor writing `BranchArm { condition: , body: ... }` directly produces a type-checkable value whose condition port is invalid. The Q4 Pattern-2 receipt leans on the condition field as the load-bearing structural distinction between `BranchEffect` and `ParallelEffect`; if that field admits invalid ports, the receipt degrades to convention-level enforcement and the coproduct dissolution argument collapses. R2 replaces this with a typed-witness (`BoolPortRef`) whose field type itself rejects invalid ports — the invariant is carried by the type, not by the constructor. Additional benefit: the `BoolPortRef` primitive is reusable for other Bool-typed-port consumers (e.g., the computation substrate's Branch behavior condition slot) as a Track 9 primitive graduation. + +**R-alt-F — host `WorkflowEffect` on a new `WorkflowDeclaration` kind.** Rejected in R2 in favor of user-declared `data` typed `WorkflowEffect`. See §"Authority site for `WorkflowEffect`" for the full argument. Short version: a new declaration kind adds substrate concept without capability gain — `data` already provides name + type + value hosting, and the type annotation (`WorkflowEffect`) carries the workflow-specificity. Per `feedback_std_over_patterns`, reuse the generic surface rather than enumerating special cases. + +**R-alt-G — defer authority-site selection to Part 2.** Rejected in R2 after the PR #531 codex review BLOCKING finding. Leaving "Stage 2b can read `WorkflowEffect` values from any declaration slot the user happens to declare them on" in the Open Questions is a Q5 single-authority violation: "any slot" is not a single authority. R2 locks the authority site in §"Authority site for `WorkflowEffect`" — user-declared `data` declarations typed `WorkflowEffect`, period. + --- ## Cross-references From ce34b0caf4cd2fcc8fdb6a26154702bea446ae98 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:22:14 -0400 Subject: [PATCH 09/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 13 +++++++------ docs/lane2-compile-time-proofs.md | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 256198bf239..7a1c871a74e 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -13,7 +13,7 @@ Stage 2b's pre-DB-18 design (`lane2-compile-time-proofs.md` §Stage 2b) assumed a workflow is a linear `List` — `compose_effects` walks the list, composes effect shapes, emits a verdict. That shape is correct for the narrow case (GCP Secret Manager upsert → STS Exchange → IAM grant, all linear) but the escalation clause in the same section already names the extension: *"if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension."* -DB-18 is that legitimate design extension, moved up-front rather than deferred. The substrate gains **one new reflected type** (`WorkflowEffect`) with **one helper record** (`BranchArm`); the computation substrate is untouched (no new `Behavior` variant, no `LoopBound` change); the effect algebra (`compose_effects`, `CompositionVerdict`) is untouched. Stage 2b's lens matches on `LinearEffect` only and emits explicit diagnostics on the other three variants, each naming the downstream stage that will consume it. The diagnostic is a first-class `Diagnostic`, not a silent skip — the fail-closed contract from INVARIANTS §C-8 applies. +DB-18 is that legitimate design extension, moved up-front rather than deferred. The substrate gains **one new reflected coproduct** (`WorkflowEffect`), **one helper record** (`BranchArm`), and **one Track 9-style typed opaque handle** (`BoolPortRef`, the R2 third Track 9 primitive after `ParamRef` and `TransformRef`). The computation substrate is untouched (no new `Behavior` variant, no `LoopBound` change); the effect algebra (`compose_effects`, `CompositionVerdict`) is untouched. The authority site for `WorkflowEffect` values is locked to user-declared `data` declarations typed `WorkflowEffect` — no new declaration kind, no sidecar table. Stage 2b's lens matches on `LinearEffect` only and emits explicit diagnostics on the other three variants, each naming the downstream stage that will consume it. The diagnostic is a first-class `Diagnostic`, not a silent skip — the fail-closed contract from INVARIANTS §C-8 applies. `bool_port_of` (the `BoolPortRef` constructor) returning `None` is a fail-closed boundary at the caller: lowering emits `Diagnostic::BranchConditionNotBool`, never silently absorbs the absence. `WorkflowEffect` is the input-structure carrier. `CompositionVerdict` (PR #529) is the output-verdict carrier. They meet at exactly one edge: Stage 2b's lens projects `LinearEffect { ops }` through `compose_effects(ops)` to obtain a `CompositionVerdict`; other `WorkflowEffect` variants produce diagnostics without invoking the algebra. @@ -478,12 +478,13 @@ The director chat owns the call on each of these. Silent in-flight patches destr - `feedback_substrate_principle_audit` — Q1–Q6 (§"Substrate principle audit" cites all six). - `feedback_state_space_vs_behavioral_invariants` — cardinality invariants via `NonEmptyList` / `NonSingletonList`; rejected alternative R-alt-A. - `feedback_lenses_not_passes` — rejected alternative R0 (lens-level re-derivation). -- `feedback_no_metadata_markers` — `BranchArm.condition: PortId` is a typed handle, not a string marker. -- `feedback_fail_closed_discipline` — `report_unsupported_variant` produces a `Diagnostic`, not a silent skip. -- DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. +- `feedback_no_metadata_markers` — `BranchArm.condition: BoolPortRef` is a typed opaque handle, not a string marker or raw index. +- `feedback_fail_closed_discipline` — `report_unsupported_variant` produces a `Diagnostic`, not a silent skip; `bool_port_of` returning `None` must be surfaced as `Diagnostic::BranchConditionNotBool` at the caller (Part 2 Acceptance item 4). +- `feedback_std_over_patterns` — authority-site decision reuses `data` rather than inventing `WorkflowDeclaration` (R-alt-F rejection). +- DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. Also: the `ParamRef` / `TransformRef` Track 9 primitive pattern that `BoolPortRef` follows (R2 third primitive graduation). - DB-16 (`design-db16-refined-generic-substitution.md`) — worked example of Part 1 design / Part 2 impl split; mirrors DB-18's scope discipline. -- PR #529 (`design-composed-effect-reshape.md`) — `CompositionVerdict` authority that DB-18's `LinearEffect` path delegates to. -- ROADMAP (`../ROADMAP.md` and `../src/v3/ROADMAP.md`) — Lane 2 Stage 2b entry; this DB advances the row from planned lens-only to substrate-carrier + lens per director escalation. +- PR #529 (`design-composed-effect-reshape.md`) — `CompositionVerdict` authority that DB-18's `LinearEffect` path delegates to. Merged 2026-04-18 as `8c7e7acdd`. +- ROADMAP (`../ROADMAP.md` and `../src/v3/ROADMAP.md`) — Lane 2 Stage 2b entry; this DB advances the row from planned lens-only to substrate-carrier + lens per director escalation. Track 9 line 763-772 references for primitive-graduation discipline. --- diff --git a/docs/lane2-compile-time-proofs.md b/docs/lane2-compile-time-proofs.md index 727129d5d57..d3cb8ef9aee 100644 --- a/docs/lane2-compile-time-proofs.md +++ b/docs/lane2-compile-time-proofs.md @@ -102,21 +102,28 @@ Lens reads each operation's declared `idempotent` modifier AND derives from path **Escalation:** if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension. -**Input structure (DB-18) — `WorkflowEffect` substrate carrier.** The escalation clause above named the extension that DB-18 now locks in: workflow control-flow shape is promoted from "something the lens reconstructs from DAG shape at walk time" (a heuristic, which `feedback_lenses_not_passes` rejects) to a first-class substrate carrier `WorkflowEffect` that lowering produces and lenses read structurally. Shape: +**Input structure (DB-18) — `WorkflowEffect` substrate carrier.** The escalation clause above named the extension that DB-18 now locks in: workflow control-flow shape is promoted from "something the lens reconstructs from DAG shape at walk time" (a heuristic, which `feedback_lenses_not_passes` rejects) to a first-class substrate carrier `WorkflowEffect` that lowering produces and lenses read structurally. Shape (R2, post-ChatGPT-review and codex-review response): ``` type WorkflowEffect - = LinearEffect { ops: NonEmptyList } + = LinearEffect { ops: List } // empty = monoidal identity | BranchEffect { arms: NonSingletonList } | LoopEffect { body: WorkflowEffect } | ParallelEffect { branches: NonSingletonList } -type BranchArm { condition: PortId; body: WorkflowEffect } +type BranchArm { condition: BoolPortRef; body: WorkflowEffect } + +// Track 9-style typed opaque handle — third primitive graduation +// after ParamRef / TransformRef (DB-9 R2.1). Sole constructor +// `bool_port_of(dag, port) -> BoolPortRef?` validates the port's +// declared type is Bool; no unsafe escape hatch. Callers emit +// Diagnostic::BranchConditionNotBool on None per C-8. +type BoolPortRef // opaque ``` -Stage 2b is the initial consumer with **`LinearEffect`-only scope**: the lens matches on `LinearEffect` and delegates to `compose_effects(ops |> to_list) -> CompositionVerdict` (post-PR #529). The other three variants emit explicit fail-closed diagnostics (C-8) naming the downstream stage that will consume them — `BranchEffect` → Stage 2d branch-wise composition, `LoopEffect` → Stage 2d fixpoint / body convergence, `ParallelEffect` → Stage 2e parallelism-as-lens with commutativity witness. No silent skip; a `WorkflowEffect` the Stage 2b lens cannot verdict produces a `Diagnostic` explaining which variant, which downstream stage, and why. The `analyze_workflow` signature above becomes `fn analyze_workflow(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport`; the NodeId-indexed form in the draft above is the pre-DB-18 sketch. `WorkflowEffect` and `CompositionVerdict` (post-PR #529) coexist on orthogonal axes — `WorkflowEffect` is the input-structure carrier, `CompositionVerdict` is the output-verdict carrier; no enclosing record pairs them (preserving the lesson PR #529 R3 locked). +Authority site: user-declared `data` declarations typed `WorkflowEffect` (one value per qualifying declaration; no sidecar table, no alternative hosting). Stage 2b is the initial consumer with **`LinearEffect`-only scope**: the lens matches on `LinearEffect` and delegates to `compose_effects(ops) -> CompositionVerdict` (post-PR #529; empty ops returns `IdempotentComposition` — the monoidal identity). The other three variants emit explicit fail-closed diagnostics (C-8) naming the downstream stage that will consume them — `BranchEffect` → Stage 2d branch-wise composition, `LoopEffect` → Stage 2d fixpoint / body convergence, `ParallelEffect` → Stage 2e parallelism-as-lens with commutativity witness. No silent skip; a `WorkflowEffect` the Stage 2b lens cannot verdict produces a `Diagnostic` explaining which variant, which downstream stage, and why. The `analyze_workflow` signature above becomes `fn analyze_workflow(d: Dag, workflow_decl: DeclarationId) -> WorkflowIdempotencyReport` (resolving the data-declaration's value to a `WorkflowEffect`); the NodeId-indexed form in the draft above is the pre-DB-18 sketch. `WorkflowEffect` and `CompositionVerdict` (post-PR #529) coexist on orthogonal axes — `WorkflowEffect` is the input-structure carrier, `CompositionVerdict` is the output-verdict carrier; no enclosing record pairs them (preserving the lesson PR #529 R3 locked). -Design: [design-db18-workflow-effect-carrier.md](./design-db18-workflow-effect-carrier.md) — Part 1 locks the 4-variant shape, the `BranchArm` structural distinction, the Q4 dissolution receipt, and the Q1–Q6 substrate-principle audit. **Part 2 implementation was gated on PR #529 (ComposedEffect reshape) landing — gate now cleared** (see Pre-start gate below, which #529 updated to "cleared"). Part 2 ships `type WorkflowEffect` in `src/v3/std/effects.dag`, the Rust mirror in `src/v3/compiler/src/dag.rs`, the lens in `src/v3/lenses/idempotency.dag`, and reconciles `WorkflowIdempotencyReport` with `CompositionVerdict` (see DB-18 §Open questions). +Design: [design-db18-workflow-effect-carrier.md](./design-db18-workflow-effect-carrier.md) — Part 1 R2 locks the 4-variant shape (with `LinearEffect.ops: List` allowing the monoidal identity), the typed `BoolPortRef` witness carrying the Q4 dissolution receipt, the authority-site decision (user-declared `data` typed `WorkflowEffect`), and the Q1–Q6 substrate-principle audit. **Part 2 gate cleared** by PR #529's merge (`8c7e7acdd`, 2026-04-18); Part 2 is structurally unblocked. Part 2 ships `type WorkflowEffect` + `type BranchArm` in `src/v3/std/effects.dag`, `type BoolPortRef` + `fn bool_port_of` in `src/v3/std/substrate.dag` (Track 9 graduation), the Rust mirrors in `src/v3/compiler/src/dag.rs`, the lens in `src/v3/lenses/idempotency.dag`, and reconciles `WorkflowIdempotencyReport` with `CompositionVerdict` (see DB-18 §Open questions). **Pre-start gate:** cleared. All Stage 2a follow-ups have landed — `DerivedOpEffect` collapsed into `OperationEffect` (PR #521); `EffectShape` partitioned into `IsIdempotent(IdempotentShape) | IsBreaking(BreakingShape)`; `BrokenBy`'s payload narrowed to `BreakingOperation { shape: BreakingShape }`; `ComposedEffect` removed. `compose_effects(effects: List) -> CompositionVerdict` is the algebra's output. Stage 2b consumes `CompositionVerdict` directly; callers pair it with their own input `List` for diagnostic rendering. The Stage 2b `WorkflowIdempotencyReport` shape above is one layer higher (a lens report with diagnostic), not a duplication of the algebra verdict — when Stage 2b implements it, the report must match on `CompositionVerdict` and project through `BrokenBy.first_breaker.shape: BreakingShape`. The `breaking_op: String?` field in the draft `WorkflowIdempotencyReport` shape above is a design placeholder from before the Stage 2a partition landed; Stage 2b's implementer should replace it with a structural carrier derived from `CompositionVerdict` rather than copy the flat shape. The implementer must not reintroduce a `ComposedEffect`-style record that pairs `CompositionVerdict` with a sibling list field, since that reintroduces the correlated-fields incoherence R3 just removed. From 1ac587a6f740ac5d1bbd0603adba3a78495eef1a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:23:16 -0400 Subject: [PATCH 10/29] =?UTF-8?q?docs:=20DB-18=20R2=20=E2=80=94=20typed=20?= =?UTF-8?q?BoolPortRef=20witness,=20empty=20LinearEffect,=20locked=20autho?= =?UTF-8?q?rity=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to two structural reviews (ChatGPT: 3 BLOCKING; codex: 2 BLOCKING) on PR #531 R1 draft. BLOCKING (ChatGPT 1+2 / codex 1): `branch_arm_of(port, body)->Option` and raw `condition: PortId` kept the "port is Bool-typed" invariant at constructor-convention level — a non-Bool branch condition is a user-reachable modeling error, not a benign absence. R2 introduces `BoolPortRef` as a Track 9-style typed opaque handle (third Track 9 primitive after ParamRef / TransformRef from DB-9 R2.1). Sole constructor `bool_port_of(dag, port) -> BoolPortRef?` validates the port's declared type is Bool; no unsafe escape hatch. BranchArm's condition field is `BoolPortRef`, not `PortId` — raw-literal construction around a non-Bool port is a type error. Fail-closed discipline relocated to the caller boundary: lowering emits `Diagnostic::BranchConditionNotBool` on None per C-8, explicitly required as a Part 2 review gate. BLOCKING (ChatGPT 3): `LinearEffect.ops: NonEmptyList` excluded the empty word, but `compose_effects([])` returns `IdempotentComposition` (the monoidal identity). The asymmetry between carrier and algebra rejected real workflow shapes (no-op paths, empty branch arms). R2 relaxes to `List`; the empty case is the honest monoidal identity representation. BLOCKING (codex 2): Open Question §5 left authority site for WorkflowEffect values undefined ("any declaration slot") — a Q5 single-authority violation. R2 locks the authority site to user-declared `data` declarations typed `WorkflowEffect`. No new declaration kind (per feedback_std_over_patterns — reuse `data`, don't invent WorkflowDeclaration); no sidecar table (a WorkflowEffect is single-declaration-local, unlike DB-9 R2.1's Cluster sidecar). Closes R1 Open Q §5. Non-blocking (codex): lane2-compile-time-proofs.md said PR #529 gate was cleared while DB-18 treated it as pre-start dependency. R2 syncs — DB-18 Pre-start gate now reads "CLEARED" with the commit SHA preserved for record; the original gate rationale is preserved as a rejected-alternative-style trail. Non-blocking (ChatGPT): Acceptance item 1 tightened — locked VARIANTS and MANDATORY fields now; additive extension fields (LoopEffect.bound for Stage 2d, ParallelEffect.commutativity for Stage 2e) explicitly flagged as downstream-binding refinements. Fixes the doc-coherence gap between Open Q §3 ("Stage 2d will almost certainly need bound") and the previous "exact payload shapes" language. Also adds R-alt-E (raw PortId rejection) and R-alt-G (deferred-host rejection) to the Rejected-alternatives list, preserving the design rationale for reviewers who hit this design again. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design-db18-workflow-effect-carrier.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 7a1c871a74e..aa14d6d188f 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -318,10 +318,17 @@ This is deliberate. PR #529's R3 removed `ComposedEffect { operations, verdict } - `EffectShape` partitioning (`IdempotentShape` / `BreakingShape`). Per PR #529, already landed in Stage 2a follow-up. - `OperationEffect`, `BreakingOperation`, `CompositionVerdict`, `IdempotencyEvidence`, `ModifierCheck`. Untouched. - The computation substrate (`Behavior`, `LoopBound`, `Cluster`). Untouched. `Behavior` stays at five variants. -- The reflected `Dag` record. Untouched. No new sidecar table. +- The reflected `Dag` record. Untouched. No new sidecar table for workflows (per §"Authority site for `WorkflowEffect`" — data-declaration value slot is the host). +- Existing Track 9 primitives (`NonEmptyList`, `NonSingletonList`, `ParamRef`, `TransformRef`). Untouched by shape change; `BoolPortRef` joins them as a new peer primitive without modifying any existing one. - `WorkflowEffectConcern` (existing record at `src/v3/std/effects.dag:669–673` post-PR #529; was `:565–569` pre-#529). This is a diagnostic-construction helper, not an input carrier; remains as-is. Part 2 may or may not project through it for the `LinearEffect` diagnostic construction. - V2 `dsl/std/effects.dag`. V3-only, per the same scope discipline PR #529 applied. +### What DB-18 DOES add (summary) + +- `type WorkflowEffect` (new coproduct) and `type BranchArm` (new record) in `src/v3/std/effects.dag`. +- `type BoolPortRef` (new Track 9 typed opaque handle) with `fn bool_port_of` and `fn port_of` in `src/v3/std/substrate.dag`. Third Track 9 primitive after `ParamRef` / `TransformRef`. +- Authority-site convention: user-declared `data` typed `WorkflowEffect` is the host. No substrate-addition needed — the `data` declaration surface already exists. + --- ## Worked example — workflow shapes under DB-18 @@ -340,13 +347,11 @@ Encoded as: ``` WorkflowEffect::LinearEffect { - ops: NonEmptyList { - first: OperationEffect { operation_name: "Secret.upsert", shape: IsIdempotent(UpsertEffect{..}) } - rest: [ - OperationEffect { operation_name: "STS.exchange", shape: IsIdempotent(ReadEffect) } - OperationEffect { operation_name: "IAM.grant", shape: IsIdempotent(UpsertEffect{..}) } - ] - } + ops: [ + OperationEffect { operation_name: "Secret.upsert", shape: IsIdempotent(UpsertEffect{..}) } + OperationEffect { operation_name: "STS.exchange", shape: IsIdempotent(ReadEffect) } + OperationEffect { operation_name: "IAM.grant", shape: IsIdempotent(UpsertEffect{..}) } + ] } ``` From c7120494c5a7989f11b1df023575c65545c7dea3 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 12:38:25 -0400 Subject: [PATCH 11/29] =?UTF-8?q?docs:=20DB-18=20R2=20propagation=20?= =?UTF-8?q?=E2=80=94=20two=20stale=20pre-R2=20references=20in=20constraint?= =?UTF-8?q?s=20+=20Pattern=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatGPT review at bb08594f flagged "two different carriers described" because R2 was mid-edit at that SHA. Most of the propagation landed in 1ac587a6f, but two genuine gaps remained: 1. Constraint 3 (Bounded kernel invariant) still cited `LinearEffect.ops: NonEmptyList` — updated to `List` with an explicit "empty list is a well-defined leaf" clause. 2. Pattern 3 (Algebraic-form) described the carrier as "the free monoid on OperationEffect less the empty word" — inconsistent with R2's empty-word inclusion. Updated to read "the free monoid on OperationEffect — including the empty word, which is the monoidal identity the R2 relaxation explicitly preserves," matching the Q1 audit and the worked examples. The reviewer's other concerns ("Q4 table still cites PortId", "audit Q1/Q2/Q6 still reasons about NonEmptyList", "worked examples", "acceptance", "lane2 subsection", "authority site still open") were already resolved by R2 at 1ac587a6f — their review's line citations are against the pre-R2 WIP snapshot, not the current tip. All remaining mentions of `PortId` in the doc are now in appropriate historical/constructor-signature context (R1 revision history, R-alt-E rejection record, bool_port_of's input-parameter type, port_of's return type, Worked Example C's authoring comment). All remaining mentions of `NonEmptyList` are in Track 9 primitive references (the family still exists; DB-18 just doesn't consume it post-R2) or historical R1 references. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design-db18-workflow-effect-carrier.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index aa14d6d188f..960d772703a 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -31,7 +31,7 @@ DB-18 is that legitimate design extension, moved up-front rather than deferred. 1. **`CompositionVerdict` authority preserved.** Post-PR #529, `compose_effects(List) -> CompositionVerdict` is the sole effect-algebra output. DB-18 does not introduce a parallel verdict carrier. `LinearEffect`'s lens delegates to `compose_effects`; other variants do not compose at Stage 2b. 2. **Five-behavior computation substrate untouched.** `Behavior = Value | Transform | Branch | Loop | Bind` remains at five variants. `WorkflowEffect` lives in the *type substrate* (declarations), not the computation substrate. Parallel lens tests (`test_thesis_five_behavior_variants`-style) stay green by construction. -3. **Bounded kernel invariant.** `WorkflowEffect` is recursive through its own variant fields; the recursion terminates at `LinearEffect.ops: NonEmptyList` (no further `WorkflowEffect` children). Finite by construction because the substrate is a DAG (no cycles in `WorkflowEffect`-typed edges). +3. **Bounded kernel invariant.** `WorkflowEffect` is recursive through its own variant fields; the recursion terminates at `LinearEffect.ops: List` (no further `WorkflowEffect` children; empty list is a well-defined leaf). Finite by construction because the substrate is a DAG (no cycles in `WorkflowEffect`-typed edges). 4. **Illegal states unrepresentable at the type level.** Cardinality invariants use Track 9 primitives (`NonEmptyList`, `NonSingletonList`). No `List` with a "lowering guarantees ≥1" comment; no `Bool + Option` pairs that admit contradictions. 5. **Fail-closed at diagnostic boundaries (INVARIANTS §C-8).** Every branch that does not lead to a `CompositionVerdict` produces a `Diagnostic` explaining which variant was encountered, which downstream stage consumes it, and — if the user can reshape the workflow into a `LinearEffect` form — how. No `Option`, no `Result`, no silent skip. 6. **Stage 2a follow-up authority preserved.** The `CompositionVerdict`/`BreakingOperation`/`IdempotentShape`/`BreakingShape` partitioning landed in PR #529 is consumed by DB-18's `LinearEffect`-path without modification. DB-18 does not reshape `EffectShape`, does not re-materialize `ComposedEffect`, does not pair `CompositionVerdict` with a sibling list field (which would reintroduce the correlated-fields incoherence PR #529 dissolved). @@ -158,7 +158,7 @@ The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonLi **Pattern 3 — Algebraic-form (traces to intro/elim of algebraic structures).** The four variants trace to four distinct categorical operations on effect algebras: -- `LinearEffect` = **monoidal composition (∘)**. `compose_effects` already witnesses this: a linear sequence of effects composes as `∘` with `ReadEffect` (identity on state) as the unit. The `CompositionVerdict` is the algebra's output for this case. The carrier `NonEmptyList` is the free monoid on `OperationEffect` less the empty word. +- `LinearEffect` = **monoidal composition (∘)**. `compose_effects` already witnesses this: a linear sequence of effects composes as `∘` with `ReadEffect` (identity on state) as the unit on the effect-shape side AND with the empty list `[]` as the unit on the carrier side. The `CompositionVerdict` is the algebra's output for this case (`compose_effects([])` = `IdempotentComposition`, the identity verdict). The carrier `List` is the free monoid on `OperationEffect` — including the empty word, which is the monoidal identity the R2 relaxation explicitly preserves. - `BranchEffect` = **coproduct (∨) / lattice meet over arms**. Exactly one arm executes at runtime; the verdict must hold for whichever arm is taken. At compile time this is a `∨`-over-arms: the workflow is idempotent iff every arm's workflow is idempotent. Structurally distinct from `∘` — composition order does not matter for arms that are alternatives, and the per-arm verdicts combine via lattice meet, not via monoidal multiplication. - `LoopEffect` = **fixpoint (μ) / iteration**. The body is re-applied 0..N times (bound structure tracked separately, potentially in Part 2). Idempotency under iteration demands the body itself be idempotent (`f ∘ f = f`) — a strictly stronger condition than linear composition, because monoidal composition of two different non-idempotent effects can still converge, but iteration of one non-idempotent effect cannot. - `ParallelEffect` = **concurrent product (⊗) / commutative composition**. Branches execute concurrently and require algebraic commutativity on the target state to compose safely. The verdict's validity depends on commutativity evidence (open question §1 below — Part 2 or Stage 2e may extend the carrier with a `commutativity` witness). Distinct from both `∘` (order-preserving) and `∨` (alternatives): `⊗` is unordered AND concurrent. From c7b8357473b68922099f60ddef3cfdad5ef0096a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 13:04:23 -0400 Subject: [PATCH 12/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 960d772703a..c8c23d1c8fe 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -231,6 +231,52 @@ All six audit questions stamp cleanly. The full six-question audit is recorded i This section closes the R1 Open Question §5 (which read "Does the substrate need a `WorkflowDeclaration` record to host WorkflowEffect? ... Open for Part 2 design"). R2 answers: no new kind needed; `data` is the host; the answer is locked in Part 1, not deferred. +### Source-to-handle contract — how a user-authored condition becomes a `BoolPortRef` + +The previous subsection locked *where* `WorkflowEffect` values live. This subsection locks *how* a user-authored `BranchArm.condition` reaches its `BoolPortRef` type. Without this rule, Part 2 could invent (a) name-keyed port recovery (violating `feedback_no_metadata_markers`), (b) a raw-id literal escape hatch (violating structural-opacity of substrate handles), or (c) lowering-time synthesis of a bool witness with no source anchor (violating `feedback_declare_facts_dont_derive`). R2.2 (this subsection, added in response to PR #531 ChatGPT review's second BLOCKING) locks the single legal path. + +**Contract (locked, Part 1):** + +1. **Surface form.** A `BranchArm.condition` is authored as an **ordinary Bool-typed expression** in the surface language — identical to the syntax the user writes for any Bool-typed value elsewhere. No DB-18-specific syntax. No port-lookup function. Example: + + ``` + data my_flow: WorkflowEffect = BranchEffect { + arms: [ + BranchArm { + condition: auth_state == AuthOk // Bool-typed expression + body: LinearEffect { ops: [Secret.get] } + } + BranchArm { + condition: retry_count < max_retries // Bool-typed expression + body: LinearEffect { ops: [STS.exchange] } + } + ] + } + ``` + + The user writes the condition as an expression; lowering handles the handle-wrapping. + +2. **Lowering path (single authority).** When lowering encounters a `BranchArm.condition` surface-level expression: + 1. Lower the expression into a computation sub-DAG via the standard value-expression → sub-DAG path (no DB-18-specific pipeline). + 2. Take the sub-DAG's root output port. + 3. Apply `bool_port_of(dag, port)`. + 4. On `Some(bool_ref)`: construct `BranchArm { condition: bool_ref, body: }`. + 5. On `None` (port's declared type is not Bool): emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` with the span pointing at the source expression (not the surrounding BranchArm or WorkflowEffect). Do NOT construct a `BranchArm` on the `None` path. + + This path is the SOLE source → `BoolPortRef` recovery mechanism. No alternative. + +3. **Rejected alternative source forms:** + - **Named-port lookup** (e.g., `condition: port("my_branch_condition")`). Violates `feedback_no_metadata_markers` (string-keyed structural recovery). + - **Raw `NodeId` / `PortId` literal** (e.g., `condition: NodeId(42)`). Violates structural opacity of substrate handles; a `BoolPortRef` must come from `bool_port_of`, not from a user-authored integer. + - **Lowering-time synthesis without source anchor** (e.g., lowering fabricates a bool witness when the user didn't author one). Violates `feedback_declare_facts_dont_derive`. + - **Any path that produces a `BoolPortRef` without going through `bool_port_of`.** `BoolPortRef` has no unsafe constructor; this rejection is enforced by the Track 9 primitive's own shape. + +4. **Fail-closed span discipline.** The `Diagnostic::BranchConditionNotBool` span points at the source expression of the condition, not at the BranchArm or WorkflowEffect wrapper. User sees "this specific condition is not Bool-typed" with a pointer to the exact surface form that needs fixing. Part 2's Acceptance item 4 names this explicitly as a Part 2 review gate — a diagnostic pointing at the wrong span (or absent entirely) fails Part 2 review. + +**Why the expression-based form (and not a dedicated port-reference syntax).** Per `feedback_std_over_patterns` (the same rationale that chose `data` over `WorkflowDeclaration` for the host): reuse existing surface. The user already writes Bool-typed expressions for every other Bool-consuming slot (`if then ... else ...`, refinements `where `, modifier predicates). Adding a DB-18-specific syntax for branch conditions would enumerate a special case. The value-expression → sub-DAG → port → `bool_port_of` path is zero-new-syntax. + +**Why this belongs in Part 1 (not Part 2).** The reviewer's concern is explicit: Part 2 has "room to invent a second mechanism" if Part 1 doesn't lock it. Leaving this in Open Questions reopens the Q5 single-authority question at the source boundary, which is the concrete axis Q5 is supposed to close. R2.2 locks it in writing; Part 2's implementation is verified against this contract. + ### Consumer contract — Stage 2b (LinearEffect-only scope) Stage 2b's `analyze_workflow` lens walks a `WorkflowEffect` value, dispatching per variant. Only `LinearEffect` yields a `CompositionVerdict`; the other three variants produce explicit diagnostics. Pseudocode shape (not the Part 2 implementation): From 3c61feb73d92d2296626a483951c28750e033f02 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 13:05:15 -0400 Subject: [PATCH 13/29] =?UTF-8?q?docs:=20DB-18=20R2.2=20=E2=80=94=20lock?= =?UTF-8?q?=20source-to-handle=20contract=20for=20BranchArm.condition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #531 ChatGPT review round 3 (at 1ac587a6f) accepted the R2 carrier direction and the R2.1 propagation fix, and flagged ONE remaining BLOCKING gap: the R2 doc locked *where* WorkflowEffect values live (user-declared `data` typed WorkflowEffect) but not *how* a user writes a condition expression that lowering resolves to a BoolPortRef. Without that lock, Part 2 could invent three distinct single-authority escape hatches: (a) name-keyed port recovery (violating feedback_no_metadata_markers), (b) raw NodeId/PortId literals (violating structural opacity), or (c) lowering-time synthesis of a bool witness without a source anchor (violating feedback_declare_facts_dont_derive). Each would reopen the Q5 single-authority question at the source boundary. R2.2 adds §"Source-to-handle contract" under the existing Authority Site section, locking the single legal path: source: ordinary Bool-typed expression (no DB-18-specific syntax) lowering: expression → sub-DAG → output port → bool_port_of fail-closed: Diagnostic::BranchConditionNotBool on None, with span pointing at the source expression (not the wrapping BranchArm) rejected: named-port lookup, raw-id literals, anchor-less synthesis Per feedback_std_over_patterns, reuses the existing Bool-expression surface (same form used by if/where/modifier predicates) rather than inventing a DB-18 port-reference syntax. Part 1 acceptance item 7 added to lock the contract. Part 2 acceptance item 4 tightened (source-span-precise diagnostic) and new item 4b added (single source-to-handle path with three escape hatches explicitly rejected as Part 2 review gates). This closes the ChatGPT review round 3 BLOCKING; the review's other observations (R2 direction good, R2.1 propagation good, LOOP HEALTH converging) are carried forward unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design-db18-workflow-effect-carrier.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index c8c23d1c8fe..04a6bf25958 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -23,7 +23,9 @@ DB-18 is that legitimate design extension, moved up-front rather than deferred. - **R0** (rejected; pre-dispatch): "Stage 2b is lens-only — model workflows as `List` produced by the lens at walk time from the DAG's Bind chain." Rejected because it re-derives workflow structure heuristically from DAG shape at lens time, violating `feedback_lenses_not_passes` ("heuristic = missing physics"). A workflow's control-flow shape is an input fact to the algebra, not something to reconstruct. - **R1** (superseded by R2): Workflow control-flow shape lifted into the substrate as a first-class four-variant carrier `WorkflowEffect`; `BranchArm { condition: PortId, body: WorkflowEffect }` as the Pattern-2 distinguisher between `BranchEffect` and `ParallelEffect`; `LinearEffect { ops: NonEmptyList }`. ChatGPT review (2026-04-18) flagged three blocking structural concerns: (a) `branch_arm_of(...) -> Option` weakens fail-closed — a non-Bool condition port is a user-reachable modeling error, not a benign "absent" case; (b) raw `PortId` admits the wrong port kind unless every producer obeys convention, keeping the BranchEffect/ParallelEffect distinction behavioral rather than type-level (directly regresses the Q4 receipt it claims to carry); (c) excluding the empty word from `LinearEffect.ops` creates asymmetry with `compose_effects(List)` — the monoidal identity is a real workflow shape (no-op paths, empty branch arms) with no honest representation. -- **R2** (current): Response to the three R1 blocking items. (a+b) Introduce `BoolPortRef` as a Track 9-style typed opaque handle whose sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` fails closed at construction — a non-Bool port returns `None` and the lowering caller emits a `Diagnostic` per C-8. `BranchArm.condition` is typed `BoolPortRef` (not raw `PortId`), so non-Bool conditions are unrepresentable at the type level; raw-literal construction of `BranchArm` cannot produce an invalid arm because `BoolPortRef` has no unsafe constructor. The Q4 Pattern-2 dissolution receipt is now carried by the *typed witness* rather than by constructor convention. (c) Relax `LinearEffect.ops` from `NonEmptyList` to `List`. Empty `LinearEffect` is the monoidal identity — `compose_effects([])` already returns `IdempotentComposition` (the identity verdict); rejecting it would make the carrier narrower than the algebra. This is also the canonical form for no-op paths (e.g., a branch arm whose body is "do nothing"). +- **R2** (superseded in-place by R2.2 propagation): Response to the three R1 blocking items. (a+b) Introduce `BoolPortRef` as a Track 9-style typed opaque handle whose sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` fails closed at construction — a non-Bool port returns `None` and the lowering caller emits a `Diagnostic` per C-8. `BranchArm.condition` is typed `BoolPortRef` (not raw `PortId`), so non-Bool conditions are unrepresentable at the type level; raw-literal construction of `BranchArm` cannot produce an invalid arm because `BoolPortRef` has no unsafe constructor. The Q4 Pattern-2 dissolution receipt is now carried by the *typed witness* rather than by constructor convention. (c) Relax `LinearEffect.ops` from `NonEmptyList` to `List`. Empty `LinearEffect` is the monoidal identity — `compose_effects([])` already returns `IdempotentComposition` (the identity verdict); rejecting it would make the carrier narrower than the algebra. This is also the canonical form for no-op paths (e.g., a branch arm whose body is "do nothing"). +- **R2.1** (propagation fix): PR #531 ChatGPT review round 2 flagged two stale R1 references that R2's substrate-section edits had not propagated through the rest of the doc: Constraint 3 (Bounded kernel invariant) still cited `LinearEffect.ops: NonEmptyList`, and Pattern 3 (Algebraic-form) described the carrier as "the free monoid on `OperationEffect` less the empty word." Both fixed at the same content location as their §Substrate changes / §Q1 audit siblings — single answer to the legal state space across the doc. +- **R2.2** (current — source-to-handle lock): PR #531 ChatGPT review round 3 accepted the R2 carrier direction + R2.1 propagation, but flagged one remaining BLOCKING gap: the doc locks *where* `WorkflowEffect` lives (user-declared `data` typed `WorkflowEffect`) but not *how* a user-authored condition expression becomes a `BoolPortRef`. Without that, Part 2 could invent name-keyed port recovery, raw-id literals, or lowering-time synthesis — three distinct single-authority escape hatches. R2.2 adds §"Source-to-handle contract — how a user-authored condition becomes a `BoolPortRef`": the condition is an ordinary Bool-typed expression; lowering runs the standard expression-to-sub-DAG path, takes the output port, wraps via `bool_port_of`, and fails closed with source-span-precise `Diagnostic::BranchConditionNotBool` on `None`. The three escape hatches are rejected in writing. This closes the Q5 single-authority question at the source boundary, not just at the host boundary. --- @@ -452,14 +454,16 @@ Part 1 (this design doc, no code) is accepted when the design locks: 4. `LinearEffect` is the Stage 2b consumer; the other three variants produce `Diagnostic` via `report_unsupported_variant` with the downstream stage name. Empty `LinearEffect.ops` is the monoidal identity, consumed by `compose_effects([])` = `IdempotentComposition`. 5. `CompositionVerdict` and `WorkflowEffect` coexist on orthogonal axes — no enclosing record pairs them; `LinearEffect`'s dispatch is the sole edge between them. 6. `WorkflowEffect`'s authority site is locked to user-declared `data` declarations typed `WorkflowEffect` (§"Authority site for WorkflowEffect"). No sidecar table; no parallel hosting; no deferred host-selection decision. -7. Part 2 pre-start gate: PR #529 merged 2026-04-18 (`8c7e7acdd`), clearing the gate. Part 2 dispatch is structurally unblocked. +7. The source-to-handle contract for `BranchArm.condition` is locked (§"Source-to-handle contract"). Condition is an ordinary Bool-typed expression; lowering runs the standard expression → sub-DAG → port → `bool_port_of` path; fail-closed `Diagnostic::BranchConditionNotBool` on `None`. Named-port lookup, raw-id literals, and source-anchorless synthesis are rejected paths, not Part 2 judgment calls. +8. Part 2 pre-start gate: PR #529 merged 2026-04-18 (`8c7e7acdd`), clearing the gate. Part 2 dispatch is structurally unblocked. Part 2 (follow-up implementation PR) must satisfy: 1. `src/v3/std/effects.dag` declares `type WorkflowEffect` and `type BranchArm` per the substrate-changes section (with `LinearEffect.ops: List`, `BranchArm.condition: BoolPortRef`). 2. `src/v3/std/substrate.dag` (or equivalent Track 9 primitive site) declares `type BoolPortRef` + `fn bool_port_of(dag, port) -> BoolPortRef?` + `fn port_of(bool_ref) -> PortId` per §"Add `BoolPortRef` as a Track 9-style substrate integrity primitive." Primitive graduation trigger: DB-18 BranchArm is the first consumer. 3. Rust mirror in `src/v3/compiler/src/dag.rs` (or equivalent sidecar file) declares `WorkflowEffect`, `BranchArm`, and `BoolPortRef` as `enum` and `struct`s; reflection-invariant test (`m2_field_access_binding_test.rs`-style) locks the two sides together. -4. **Fail-closed on `bool_port_of` returning `None`.** Every lowering site that calls `bool_port_of` must emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` on `None` and must NOT construct a `BranchArm` with that port via any alternative path. Silent `None` absorption is a Part 2 review gate — reject any PR that pattern-matches `bool_port_of(...)` result without a Diagnostic emission branch. +4. **Fail-closed on `bool_port_of` returning `None`, with source-span precision.** Every lowering site that calls `bool_port_of` must emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` on `None` and must NOT construct a `BranchArm` with that port via any alternative path. The `span` must point at the source-level condition expression (not the surrounding BranchArm or WorkflowEffect), per §"Source-to-handle contract" point 4. Silent `None` absorption is a Part 2 review gate — reject any PR that pattern-matches `bool_port_of(...)` result without a Diagnostic emission branch, or whose diagnostic span points at the wrong surface form. +4b. **Single source-to-handle path.** The lowering code that produces a `BranchArm.condition` value must go through exactly one path: expression → sub-DAG → output-port → `bool_port_of`. Part 2 review rejects any PR that introduces (a) a named-port lookup helper for branch conditions, (b) a raw `NodeId` / `PortId` literal parse at the BranchArm-condition site, or (c) a synthesized bool witness without a source-level expression anchor. Per §"Source-to-handle contract" point 3. 5. `src/v3/lenses/idempotency.dag` (new) declares `analyze_workflow(d: Dag, workflow_decl: DeclarationId) -> WorkflowIdempotencyReport` (resolving the declaration's value via the data-declaration accessor) and `analyze_workflow_value(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport`, dispatching per variant. `LinearEffect` calls `compose_effects`; the other three emit `Diagnostic` via `report_unsupported_variant`. 6. `WorkflowIdempotencyReport` replaces the pre-#529 `idempotent: Bool + breaking_op: String?` draft shape with a structural carrier projecting through `CompositionVerdict` (per PR #529's pre-start-gate update to `lane2-compile-time-proofs.md` §Stage 2b). Exact shape resolved at Part 2 design (see Open question §2). 7. Stage 2b acceptance fixtures from `lane2-compile-time-proofs.md` §Stage 2b (three fixtures: GCP linear green, terminal AppendEffect red, POST-create keyless failure) pass against the lens-over-`WorkflowEffect` implementation. From d7f226a96b94b8dbdc0cd755bdfe54c9f7476129 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 13:06:42 -0400 Subject: [PATCH 14/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 04a6bf25958..fe4e9b7c6eb 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -21,6 +21,8 @@ DB-18 is that legitimate design extension, moved up-front rather than deferred. ## Revision history +**Preservation-boundary note for reviewers (human and bot).** The entries below preserve rejected and superseded design shapes as an evolution record — standard DB-doc discipline (same pattern as DB-9 R2.1 preserving `MutualLoop` and R1, and DB-16 preserving its R1 lens-level approach). **The live spec uses the R2 shape — `BoolPortRef`, `List`, data-declaration host, expression-based condition source — documented in §"Substrate changes", §"Dissolution receipt", §"Substrate principle audit", §"Authority site for `WorkflowEffect`", §"Source-to-handle contract", and §"Acceptance".** Mentions of `PortId` (as a BranchArm field type), `branch_arm_of`, or `NonEmptyList` inside this §Revision history section and inside §Rejected alternatives are describing superseded shapes, not the live contract. Do not treat these references as spec contamination. + - **R0** (rejected; pre-dispatch): "Stage 2b is lens-only — model workflows as `List` produced by the lens at walk time from the DAG's Bind chain." Rejected because it re-derives workflow structure heuristically from DAG shape at lens time, violating `feedback_lenses_not_passes` ("heuristic = missing physics"). A workflow's control-flow shape is an input fact to the algebra, not something to reconstruct. - **R1** (superseded by R2): Workflow control-flow shape lifted into the substrate as a first-class four-variant carrier `WorkflowEffect`; `BranchArm { condition: PortId, body: WorkflowEffect }` as the Pattern-2 distinguisher between `BranchEffect` and `ParallelEffect`; `LinearEffect { ops: NonEmptyList }`. ChatGPT review (2026-04-18) flagged three blocking structural concerns: (a) `branch_arm_of(...) -> Option` weakens fail-closed — a non-Bool condition port is a user-reachable modeling error, not a benign "absent" case; (b) raw `PortId` admits the wrong port kind unless every producer obeys convention, keeping the BranchEffect/ParallelEffect distinction behavioral rather than type-level (directly regresses the Q4 receipt it claims to carry); (c) excluding the empty word from `LinearEffect.ops` creates asymmetry with `compose_effects(List)` — the monoidal identity is a real workflow shape (no-op paths, empty branch arms) with no honest representation. - **R2** (superseded in-place by R2.2 propagation): Response to the three R1 blocking items. (a+b) Introduce `BoolPortRef` as a Track 9-style typed opaque handle whose sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` fails closed at construction — a non-Bool port returns `None` and the lowering caller emits a `Diagnostic` per C-8. `BranchArm.condition` is typed `BoolPortRef` (not raw `PortId`), so non-Bool conditions are unrepresentable at the type level; raw-literal construction of `BranchArm` cannot produce an invalid arm because `BoolPortRef` has no unsafe constructor. The Q4 Pattern-2 dissolution receipt is now carried by the *typed witness* rather than by constructor convention. (c) Relax `LinearEffect.ops` from `NonEmptyList` to `List`. Empty `LinearEffect` is the monoidal identity — `compose_effects([])` already returns `IdempotentComposition` (the identity verdict); rejecting it would make the carrier narrower than the algebra. This is also the canonical form for no-op paths (e.g., a branch arm whose body is "do nothing"). @@ -131,7 +133,7 @@ fn bool_port_of(dag: Dag, port: PortId) -> BoolPortRef? fn port_of(bool_ref: BoolPortRef) -> PortId ``` -**On the fail-closed story for `bool_port_of` returning `Option`.** ChatGPT review flagged that `branch_arm_of(port, body) -> Option` weakens fail-closed because a non-Bool condition port is a user-reachable modeling error. R2's response inverts the concern: `bool_port_of` returning `Option` is the Track 9 primitive's internal-validation contract (matching `param_of` from DB-9 R2.1). The primitive does not itself emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. But the *lowering path that calls `bool_port_of`* is required to handle `None` by emitting a `Diagnostic::BranchConditionNotBool { port, actual_type, span }` per C-8, never by silently absorbing the absence. The primitive's Option is internal validation; the caller's Diagnostic emission is the fail-closed boundary. This is the same pattern Track 9 established for `param_of` (DB-9 R2.1): the primitive returns Option; the lowering site that consumes it is responsible for the Diagnostic. Part 2's Acceptance item names this explicitly as a required behavior on the lowering path — a silent `None` absorption in Part 2 implementation is a Part 2 review gate. +**On the fail-closed story for `bool_port_of` returning `Option`.** *This subsection describes an R1 concern and R2's resolution — the live contract is R2's `bool_port_of` + `BoolPortRef` as defined in §"Substrate changes" above. The R1 `branch_arm_of` shape mentioned below is superseded; it is named here only to make R2's inversion of the concern legible.* ChatGPT review flagged that `branch_arm_of(port, body) -> Option` weakens fail-closed because a non-Bool condition port is a user-reachable modeling error. R2's response inverts the concern: `bool_port_of` returning `Option` is the Track 9 primitive's internal-validation contract (matching `param_of` from DB-9 R2.1). The primitive does not itself emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. But the *lowering path that calls `bool_port_of`* is required to handle `None` by emitting a `Diagnostic::BranchConditionNotBool { port, actual_type, span }` per C-8, never by silently absorbing the absence. The primitive's Option is internal validation; the caller's Diagnostic emission is the fail-closed boundary. This is the same pattern Track 9 established for `param_of` (DB-9 R2.1): the primitive returns Option; the lowering site that consumes it is responsible for the Diagnostic. Part 2's Acceptance item names this explicitly as a required behavior on the lowering path — a silent `None` absorption in Part 2 implementation is a Part 2 review gate. **Why this belongs in `std/effects.dag` rather than `std/substrate.dag`.** `WorkflowEffect` is a concept of the effect algebra — it describes input shapes that `compose_effects` (and its downstream peers) dispatch over. The substrate's own declarations (`Dag`, `Declaration`, `Behavior`, `LoopBound`, `Cluster`) describe the *computation and type substrate*; the effect algebra is a lens-writable layer above that substrate. The existing file already hosts `OperationEffect`, `EffectShape`, `IdempotencyEvidence`, `CompositionVerdict` — all effect-algebra types. `WorkflowEffect` joins that family. From 5e4c6e61ffbe56d8645595c0fbf8deb29cebfcfe Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 14:03:49 -0400 Subject: [PATCH 15/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 39 +++++---------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index fe4e9b7c6eb..29918c935a1 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -4,7 +4,7 @@ **Design blocker:** DB-18 (new substrate carrier `WorkflowEffect` — a four-variant coproduct describing workflow control-flow shape for effect-algebra composition) **Consumers:** Lane 2 Stage 2b workflow idempotency lens (initial consumer; `LinearEffect`-only scope). Downstream: Stage 2d symbolic cost, Stage 2e parallelism-as-lens, Stage 2f user-declared dimensions. -**Status:** R2 — responding to PR #531 ChatGPT review's three blocking concerns on fail-closed construction, type-level witness for the BranchEffect/ParallelEffect structural distinction, and LinearEffect carrier coverage of the monoidal identity. Part 1 design-only — implementation and tests ship in the follow-up Part 2 PR. +**Status:** Part 1 design-only — implementation and tests ship in the follow-up Part 2 PR. **Companion:** [design-composed-effect-reshape.md](./design-composed-effect-reshape.md) (PR #529, landed 2026-04-18 as `8c7e7acdd`) reshapes the *output* side of the effect algebra: `ComposedEffect` is removed; `compose_effects(effects: List) -> CompositionVerdict` is the post-reshape algebra. DB-18 adds the *input* side: a typed carrier describing the workflow's control-flow structure above `List`. The two live on orthogonal axes — `WorkflowEffect` is the input structure the lens walks; `CompositionVerdict` is the output the algebra returns. They coexist; neither replaces the other. --- @@ -19,18 +19,6 @@ DB-18 is that legitimate design extension, moved up-front rather than deferred. --- -## Revision history - -**Preservation-boundary note for reviewers (human and bot).** The entries below preserve rejected and superseded design shapes as an evolution record — standard DB-doc discipline (same pattern as DB-9 R2.1 preserving `MutualLoop` and R1, and DB-16 preserving its R1 lens-level approach). **The live spec uses the R2 shape — `BoolPortRef`, `List`, data-declaration host, expression-based condition source — documented in §"Substrate changes", §"Dissolution receipt", §"Substrate principle audit", §"Authority site for `WorkflowEffect`", §"Source-to-handle contract", and §"Acceptance".** Mentions of `PortId` (as a BranchArm field type), `branch_arm_of`, or `NonEmptyList` inside this §Revision history section and inside §Rejected alternatives are describing superseded shapes, not the live contract. Do not treat these references as spec contamination. - -- **R0** (rejected; pre-dispatch): "Stage 2b is lens-only — model workflows as `List` produced by the lens at walk time from the DAG's Bind chain." Rejected because it re-derives workflow structure heuristically from DAG shape at lens time, violating `feedback_lenses_not_passes` ("heuristic = missing physics"). A workflow's control-flow shape is an input fact to the algebra, not something to reconstruct. -- **R1** (superseded by R2): Workflow control-flow shape lifted into the substrate as a first-class four-variant carrier `WorkflowEffect`; `BranchArm { condition: PortId, body: WorkflowEffect }` as the Pattern-2 distinguisher between `BranchEffect` and `ParallelEffect`; `LinearEffect { ops: NonEmptyList }`. ChatGPT review (2026-04-18) flagged three blocking structural concerns: (a) `branch_arm_of(...) -> Option` weakens fail-closed — a non-Bool condition port is a user-reachable modeling error, not a benign "absent" case; (b) raw `PortId` admits the wrong port kind unless every producer obeys convention, keeping the BranchEffect/ParallelEffect distinction behavioral rather than type-level (directly regresses the Q4 receipt it claims to carry); (c) excluding the empty word from `LinearEffect.ops` creates asymmetry with `compose_effects(List)` — the monoidal identity is a real workflow shape (no-op paths, empty branch arms) with no honest representation. -- **R2** (superseded in-place by R2.2 propagation): Response to the three R1 blocking items. (a+b) Introduce `BoolPortRef` as a Track 9-style typed opaque handle whose sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` fails closed at construction — a non-Bool port returns `None` and the lowering caller emits a `Diagnostic` per C-8. `BranchArm.condition` is typed `BoolPortRef` (not raw `PortId`), so non-Bool conditions are unrepresentable at the type level; raw-literal construction of `BranchArm` cannot produce an invalid arm because `BoolPortRef` has no unsafe constructor. The Q4 Pattern-2 dissolution receipt is now carried by the *typed witness* rather than by constructor convention. (c) Relax `LinearEffect.ops` from `NonEmptyList` to `List`. Empty `LinearEffect` is the monoidal identity — `compose_effects([])` already returns `IdempotentComposition` (the identity verdict); rejecting it would make the carrier narrower than the algebra. This is also the canonical form for no-op paths (e.g., a branch arm whose body is "do nothing"). -- **R2.1** (propagation fix): PR #531 ChatGPT review round 2 flagged two stale R1 references that R2's substrate-section edits had not propagated through the rest of the doc: Constraint 3 (Bounded kernel invariant) still cited `LinearEffect.ops: NonEmptyList`, and Pattern 3 (Algebraic-form) described the carrier as "the free monoid on `OperationEffect` less the empty word." Both fixed at the same content location as their §Substrate changes / §Q1 audit siblings — single answer to the legal state space across the doc. -- **R2.2** (current — source-to-handle lock): PR #531 ChatGPT review round 3 accepted the R2 carrier direction + R2.1 propagation, but flagged one remaining BLOCKING gap: the doc locks *where* `WorkflowEffect` lives (user-declared `data` typed `WorkflowEffect`) but not *how* a user-authored condition expression becomes a `BoolPortRef`. Without that, Part 2 could invent name-keyed port recovery, raw-id literals, or lowering-time synthesis — three distinct single-authority escape hatches. R2.2 adds §"Source-to-handle contract — how a user-authored condition becomes a `BoolPortRef`": the condition is an ordinary Bool-typed expression; lowering runs the standard expression-to-sub-DAG path, takes the output port, wraps via `bool_port_of`, and fails closed with source-span-precise `Diagnostic::BranchConditionNotBool` on `None`. The three escape hatches are rejected in writing. This closes the Q5 single-authority question at the source boundary, not just at the host boundary. - ---- - ## Constraints (non-negotiable) 1. **`CompositionVerdict` authority preserved.** Post-PR #529, `compose_effects(List) -> CompositionVerdict` is the sole effect-algebra output. DB-18 does not introduce a parallel verdict carrier. `LinearEffect`'s lens delegates to `compose_effects`; other variants do not compose at Stage 2b. @@ -133,7 +121,7 @@ fn bool_port_of(dag: Dag, port: PortId) -> BoolPortRef? fn port_of(bool_ref: BoolPortRef) -> PortId ``` -**On the fail-closed story for `bool_port_of` returning `Option`.** *This subsection describes an R1 concern and R2's resolution — the live contract is R2's `bool_port_of` + `BoolPortRef` as defined in §"Substrate changes" above. The R1 `branch_arm_of` shape mentioned below is superseded; it is named here only to make R2's inversion of the concern legible.* ChatGPT review flagged that `branch_arm_of(port, body) -> Option` weakens fail-closed because a non-Bool condition port is a user-reachable modeling error. R2's response inverts the concern: `bool_port_of` returning `Option` is the Track 9 primitive's internal-validation contract (matching `param_of` from DB-9 R2.1). The primitive does not itself emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. But the *lowering path that calls `bool_port_of`* is required to handle `None` by emitting a `Diagnostic::BranchConditionNotBool { port, actual_type, span }` per C-8, never by silently absorbing the absence. The primitive's Option is internal validation; the caller's Diagnostic emission is the fail-closed boundary. This is the same pattern Track 9 established for `param_of` (DB-9 R2.1): the primitive returns Option; the lowering site that consumes it is responsible for the Diagnostic. Part 2's Acceptance item names this explicitly as a required behavior on the lowering path — a silent `None` absorption in Part 2 implementation is a Part 2 review gate. +**Fail-closed boundary placement.** `bool_port_of` returning `Option` is the Track 9 primitive's internal-validation contract (matching `param_of`). The primitive does not itself emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. The *lowering path that calls `bool_port_of`* is required to handle `None` by emitting `Diagnostic::BranchConditionNotBool { port, actual_type, span }` per C-8, never by silently absorbing the absence. The primitive's `Option` is internal validation; the caller's `Diagnostic` emission is the fail-closed boundary. Part 2 Acceptance item 4 names this as a review gate — a silent `None` absorption fails Part 2 review. **Why this belongs in `std/effects.dag` rather than `std/substrate.dag`.** `WorkflowEffect` is a concept of the effect algebra — it describes input shapes that `compose_effects` (and its downstream peers) dispatch over. The substrate's own declarations (`Dag`, `Declaration`, `Behavior`, `LoopBound`, `Cluster`) describe the *computation and type substrate*; the effect algebra is a lens-writable layer above that substrate. The existing file already hosts `OperationEffect`, `EffectShape`, `IdempotencyEvidence`, `CompositionVerdict` — all effect-algebra types. `WorkflowEffect` joins that family. @@ -158,11 +146,11 @@ Per `feedback_coproduct_dissolution`, every new coproduct must pass the four-pat | `BranchEffect` | `ParallelEffect` | `NonSingletonList` | `NonSingletonList` | ✓ `BranchArm` ≠ `WorkflowEffect` (BranchArm carries `condition: BoolPortRef` that ParallelEffect branches do not; the typed-witness field is unrepresentable in ParallelEffect's element type) | | `LoopEffect` | `ParallelEffect` | `WorkflowEffect` | `NonSingletonList` | ✓ single vs list | -The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonList<...>` at the outer shape and recurse on `WorkflowEffect` at the element level. The distinction is carried by `BranchArm { condition: BoolPortRef, body: WorkflowEffect }` versus a plain `WorkflowEffect`: a branch arm is an arm-gated-by-a-Bool-typed-port, a parallel branch is an unconditioned concurrent workflow. `condition: BoolPortRef` is the load-bearing structural fact — and after R2 it is carried by a **typed opaque handle**, not a raw `PortId`. `BoolPortRef`'s sole constructor `bool_port_of(dag, port)` returns `None` for non-Bool ports, and has no unsafe escape hatch, so the "port is Bool-typed" invariant is witnessed by the type itself rather than by constructor convention. A raw-literal `BranchArm { condition: , body: ... }` is not type-checkable; only `BranchArm { condition: , body: ... }` is, and a `BoolPortRef` can only come from the fail-closed constructor. Pattern 2's dissolution criterion ("same shape, different label") does not apply because the `BranchArm` wrapper carries a structurally required *typed* field `ParallelEffect`'s branches do not, and the type-level witness closes the convention-level hole the R1 draft left open. ✓ does not apply. +The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonList<...>` at the outer shape and recurse on `WorkflowEffect` at the element level. The distinction is carried by `BranchArm { condition: BoolPortRef, body: WorkflowEffect }` versus a plain `WorkflowEffect`: a branch arm is an arm-gated-by-a-Bool-typed-port, a parallel branch is an unconditioned concurrent workflow. `condition: BoolPortRef` is the load-bearing structural fact, carried by a **typed opaque handle**. `BoolPortRef`'s sole constructor `bool_port_of(dag, port)` returns `None` for non-Bool ports and has no unsafe escape hatch, so the "port is Bool-typed" invariant is witnessed by the type itself rather than by constructor convention. A raw-literal `BranchArm { condition: , body: ... }` is not type-checkable; only `BranchArm { condition: , body: ... }` is, and a `BoolPortRef` can only come from the fail-closed constructor. Pattern 2's dissolution criterion ("same shape, different label") does not apply because the `BranchArm` wrapper carries a structurally required *typed* field `ParallelEffect`'s branches do not. ✓ does not apply. **Pattern 3 — Algebraic-form (traces to intro/elim of algebraic structures).** The four variants trace to four distinct categorical operations on effect algebras: -- `LinearEffect` = **monoidal composition (∘)**. `compose_effects` already witnesses this: a linear sequence of effects composes as `∘` with `ReadEffect` (identity on state) as the unit on the effect-shape side AND with the empty list `[]` as the unit on the carrier side. The `CompositionVerdict` is the algebra's output for this case (`compose_effects([])` = `IdempotentComposition`, the identity verdict). The carrier `List` is the free monoid on `OperationEffect` — including the empty word, which is the monoidal identity the R2 relaxation explicitly preserves. +- `LinearEffect` = **monoidal composition (∘)**. `compose_effects` already witnesses this: a linear sequence of effects composes as `∘` with `ReadEffect` (identity on state) as the unit on the effect-shape side AND with the empty list `[]` as the unit on the carrier side. The `CompositionVerdict` is the algebra's output for this case (`compose_effects([])` = `IdempotentComposition`, the identity verdict). The carrier `List` is the free monoid on `OperationEffect`, including the empty word as the monoidal identity. - `BranchEffect` = **coproduct (∨) / lattice meet over arms**. Exactly one arm executes at runtime; the verdict must hold for whichever arm is taken. At compile time this is a `∨`-over-arms: the workflow is idempotent iff every arm's workflow is idempotent. Structurally distinct from `∘` — composition order does not matter for arms that are alternatives, and the per-arm verdicts combine via lattice meet, not via monoidal multiplication. - `LoopEffect` = **fixpoint (μ) / iteration**. The body is re-applied 0..N times (bound structure tracked separately, potentially in Part 2). Idempotency under iteration demands the body itself be idempotent (`f ∘ f = f`) — a strictly stronger condition than linear composition, because monoidal composition of two different non-idempotent effects can still converge, but iteration of one non-idempotent effect cannot. - `ParallelEffect` = **concurrent product (⊗) / commutative composition**. Branches execute concurrently and require algebraic commutativity on the target state to compose safely. The verdict's validity depends on commutativity evidence (open question §1 below — Part 2 or Stage 2e may extend the carrier with a `commutativity` witness). Distinct from both `∘` (order-preserving) and `∨` (alternatives): `⊗` is unordered AND concurrent. @@ -179,7 +167,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q1 — Cardinality invariants.** Does any variant admit `[]` where invariant says ≥1, or singletons where ≥2? -- `LinearEffect.ops: List` — empty is explicitly allowed as the **monoidal identity** (the unit element of the free monoid on `OperationEffect`). `compose_effects([])` already returns `IdempotentComposition`; the type accepts empty for carrier/algebra symmetry. This is also the honest representation of a no-op workflow (e.g., a branch arm whose body does nothing). R1's `NonEmptyList` rejection of empty was a state-space error — the R2 relaxation fixes it. No ≥1 invariant applies here; the invariant is "a list of ops (zero or more)," matching the algebra. +- `LinearEffect.ops: List` — empty is explicitly allowed as the **monoidal identity** (the unit element of the free monoid on `OperationEffect`). `compose_effects([])` already returns `IdempotentComposition`; the type accepts empty for carrier/algebra symmetry. This is also the honest representation of a no-op workflow (e.g., a branch arm whose body does nothing). No ≥1 invariant applies here; the invariant is "a list of ops (zero or more)," matching the algebra. - `BranchEffect.arms: NonSingletonList` — a branch with 0 or 1 arms is meaningless (0: no workflow; 1: just the arm itself, use the arm's `body` directly). Type rejects both. - `LoopEffect.body: WorkflowEffect` — singleton (one body), never empty. Type is non-optional. - `ParallelEffect.branches: NonSingletonList` — 0 branches is empty parallelism; 1 branch is just the branch itself, no concurrency. Type rejects both. ✓ cardinality invariants at the type level (including the correctly-permissive empty `LinearEffect` case); no "lowering guarantees" prose required. @@ -194,7 +182,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q5 — Construction authority.** Are multiple call sites independently constructing the same fact? -- **Authority site for `WorkflowEffect` values (locked, per R2 blocking finding).** See §"Authority site for WorkflowEffect" below — `WorkflowEffect` values live exclusively on user-declared `data` declarations whose declared type is structurally `WorkflowEffect`. Exactly one `WorkflowEffect` per qualifying `Declaration`; no sidecar table, no parallel hosting. Lowering is the sole producer; lens and downstream consumers are pure readers. +- **Authority site for `WorkflowEffect` values.** See §"Authority site for WorkflowEffect" below — `WorkflowEffect` values live exclusively on user-declared `data` declarations whose declared type is structurally `WorkflowEffect`. Exactly one `WorkflowEffect` per qualifying `Declaration`; no sidecar table, no parallel hosting. Lowering is the sole producer; lens and downstream consumers are pure readers. - **Construction authority for `BranchArm`.** `BranchArm` itself is directly constructible (it is a plain record), but its validity is witnessed by the `condition: BoolPortRef` field type, NOT by a constructor. The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` is the Track 9 primitive; any lowering path that obtains a `BoolPortRef` did so via `bool_port_of` and therefore either (a) emitted a `Diagnostic::BranchConditionNotBool` on `None` or (b) proceeded with a type-level Bool witness. Direct `BranchArm { condition: bool_port_ref, body: ... }` construction is fine by design because the condition field CANNOT carry an invalid port. Raw-literal `BranchArm { condition: , ... }` is a type error. ✓ single authority on the typed-witness construction path; no convention-level escape hatch. **Q6 — Representation duality.** Can the same fact be expressed in two structurally different shapes that comparison treats differently? @@ -224,20 +212,13 @@ All six audit questions stamp cleanly. The full six-question audit is recorded i **Why `data` rather than a new `WorkflowDeclaration` kind.** Both are valid; R2 picks `data` because it is zero-substrate-expansion. The v3 substrate already hosts `data` declarations — adding `WorkflowEffect` as a declarable type in that slot is pure consumer extension of an existing surface. A new `WorkflowDeclaration` kind would be a substrate addition that duplicates what `data` already provides (name + type + value) with a specific-to-workflow type constraint; the specific-to-workflow constraint is already carried by the *type* `WorkflowEffect`. Per `feedback_std_over_patterns` ("types-in-std/ dissolves CX patterns; don't enumerate special cases"), the right move is to reuse the generic `data` surface and let the type annotation carry the specificity. -**Why not a `Dag.workflows` sidecar.** Sidecar tables are justified when a fact spans multiple declarations (e.g., DB-9 R2.1's `Dag.clusters` — an SCC member list spans the cluster's member declarations, so a per-declaration field cannot host it). A `WorkflowEffect` is a single-declaration-local value — it describes one workflow, authored on one declaration. A sidecar would be a parallel cache of facts already stored on the declaration; the consumer could drift from the authority. Per Q3 (no duplicated facts) and Q5 (single authority), the data-declaration value slot is the correct place. - -**Rejected host alternatives (for the record):** +**Why not a `Dag.workflows` sidecar.** Sidecar tables are justified when a fact spans multiple declarations (e.g., `Dag.clusters` — an SCC member list spans the cluster's member declarations, so a per-declaration field cannot host it). A `WorkflowEffect` is a single-declaration-local value — it describes one workflow, authored on one declaration. A sidecar would be a parallel cache of facts already stored on the declaration; the consumer could drift from the authority. Per Q3 (no duplicated facts) and Q5 (single authority), the data-declaration value slot is the correct place. -- **`WorkflowDeclaration` new kind.** Extra substrate concept, no capability gain over `data`. Rejected. -- **Field on `OperationDeclaration`.** Conflates per-op effect (the operation's own effect shape) with workflow-level composition (how ops compose). They are different kinds of fact. Rejected. -- **Sidecar table `Dag.workflows`.** Parallel to the declaration; would require a derivation to keep it in sync. Rejected (see above). -- **Lens-time reconstruction from DAG shape.** The R0 rejection. WorkflowEffect is authored, not derived. - -This section closes the R1 Open Question §5 (which read "Does the substrate need a `WorkflowDeclaration` record to host WorkflowEffect? ... Open for Part 2 design"). R2 answers: no new kind needed; `data` is the host; the answer is locked in Part 1, not deferred. +**Rejected host alternatives:** see §Rejected alternatives (R-alt-F for `WorkflowDeclaration`; R-alt-G for deferring the host decision). Field-on-`OperationDeclaration` and sidecar-table alternatives are enumerated below alongside the adopted path so newcomers don't re-propose them. ### Source-to-handle contract — how a user-authored condition becomes a `BoolPortRef` -The previous subsection locked *where* `WorkflowEffect` values live. This subsection locks *how* a user-authored `BranchArm.condition` reaches its `BoolPortRef` type. Without this rule, Part 2 could invent (a) name-keyed port recovery (violating `feedback_no_metadata_markers`), (b) a raw-id literal escape hatch (violating structural-opacity of substrate handles), or (c) lowering-time synthesis of a bool witness with no source anchor (violating `feedback_declare_facts_dont_derive`). R2.2 (this subsection, added in response to PR #531 ChatGPT review's second BLOCKING) locks the single legal path. +The previous subsection locked *where* `WorkflowEffect` values live. This subsection locks *how* a user-authored `BranchArm.condition` reaches its `BoolPortRef` type. Without this rule, Part 2 could invent (a) name-keyed port recovery (violating `feedback_no_metadata_markers`), (b) a raw-id literal escape hatch (violating structural-opacity of substrate handles), or (c) lowering-time synthesis of a bool witness with no source anchor (violating `feedback_declare_facts_dont_derive`). The single legal path is locked below; the three escape hatches are named as rejected so Part 2 cannot silently adopt one. **Contract (locked, Part 1):** @@ -279,8 +260,6 @@ The previous subsection locked *where* `WorkflowEffect` values live. This subsec **Why the expression-based form (and not a dedicated port-reference syntax).** Per `feedback_std_over_patterns` (the same rationale that chose `data` over `WorkflowDeclaration` for the host): reuse existing surface. The user already writes Bool-typed expressions for every other Bool-consuming slot (`if then ... else ...`, refinements `where `, modifier predicates). Adding a DB-18-specific syntax for branch conditions would enumerate a special case. The value-expression → sub-DAG → port → `bool_port_of` path is zero-new-syntax. -**Why this belongs in Part 1 (not Part 2).** The reviewer's concern is explicit: Part 2 has "room to invent a second mechanism" if Part 1 doesn't lock it. Leaving this in Open Questions reopens the Q5 single-authority question at the source boundary, which is the concrete axis Q5 is supposed to close. R2.2 locks it in writing; Part 2's implementation is verified against this contract. - ### Consumer contract — Stage 2b (LinearEffect-only scope) Stage 2b's `analyze_workflow` lens walks a `WorkflowEffect` value, dispatching per variant. Only `LinearEffect` yields a `CompositionVerdict`; the other three variants produce explicit diagnostics. Pseudocode shape (not the Part 2 implementation): From a8dbe9183ae0ed5ced1c5963bc6f19c0611e8fb6 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 14:05:30 -0400 Subject: [PATCH 16/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 32 +++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 29918c935a1..0560b5e710d 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -13,7 +13,7 @@ Stage 2b's pre-DB-18 design (`lane2-compile-time-proofs.md` §Stage 2b) assumed a workflow is a linear `List` — `compose_effects` walks the list, composes effect shapes, emits a verdict. That shape is correct for the narrow case (GCP Secret Manager upsert → STS Exchange → IAM grant, all linear) but the escalation clause in the same section already names the extension: *"if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension."* -DB-18 is that legitimate design extension, moved up-front rather than deferred. The substrate gains **one new reflected coproduct** (`WorkflowEffect`), **one helper record** (`BranchArm`), and **one Track 9-style typed opaque handle** (`BoolPortRef`, the R2 third Track 9 primitive after `ParamRef` and `TransformRef`). The computation substrate is untouched (no new `Behavior` variant, no `LoopBound` change); the effect algebra (`compose_effects`, `CompositionVerdict`) is untouched. The authority site for `WorkflowEffect` values is locked to user-declared `data` declarations typed `WorkflowEffect` — no new declaration kind, no sidecar table. Stage 2b's lens matches on `LinearEffect` only and emits explicit diagnostics on the other three variants, each naming the downstream stage that will consume it. The diagnostic is a first-class `Diagnostic`, not a silent skip — the fail-closed contract from INVARIANTS §C-8 applies. `bool_port_of` (the `BoolPortRef` constructor) returning `None` is a fail-closed boundary at the caller: lowering emits `Diagnostic::BranchConditionNotBool`, never silently absorbs the absence. +DB-18 is that legitimate design extension, moved up-front rather than deferred. The substrate gains **one new reflected coproduct** (`WorkflowEffect`), **one helper record** (`BranchArm`), and **one Track 9-style typed opaque handle** (`BoolPortRef`, the third Track 9 primitive after `ParamRef` and `TransformRef`). The computation substrate is untouched (no new `Behavior` variant, no `LoopBound` change); the effect algebra (`compose_effects`, `CompositionVerdict`) is untouched. The authority site for `WorkflowEffect` values is locked to user-declared `data` declarations typed `WorkflowEffect` — no new declaration kind, no sidecar table. Stage 2b's lens matches on `LinearEffect` only and emits explicit diagnostics on the other three variants, each naming the downstream stage that will consume it. The diagnostic is a first-class `Diagnostic`, not a silent skip — the fail-closed contract from INVARIANTS §C-8 applies. `bool_port_of` (the `BoolPortRef` constructor) returning `None` is a fail-closed boundary at the caller: lowering emits `Diagnostic::BranchConditionNotBool`, never silently absorbs the absence. `WorkflowEffect` is the input-structure carrier. `CompositionVerdict` (PR #529) is the output-verdict carrier. They meet at exactly one edge: Stage 2b's lens projects `LinearEffect { ops }` through `compose_effects(ops)` to obtain a `CompositionVerdict`; other `WorkflowEffect` variants produce diagnostics without invoking the algebra. @@ -174,7 +174,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q2 — Index/handle types.** Does a raw `Int` or `NodeId` encode something with a domain restriction? -- `BranchArm.condition: BoolPortRef` is a typed opaque handle (Track 9-style primitive, R2 graduation). The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` validates the port's declared type is Bool; there is no unsafe escape hatch. A raw `PortId` does not typecheck in `BranchArm.condition`; the field type is `BoolPortRef`, not `PortId`. Matching the `ParamRef` / `TransformRef` pattern from DB-9 R2.1 — the handle carries both "is a valid port reference" AND "is Bool-typed," with the relation folded into the type rather than carried by constructor discipline. ✓ no raw `Int`/`NodeId`/`PortId` with comment-level or constructor-level validity; the invariant is carried by the type itself. +- `BranchArm.condition: BoolPortRef` is a typed opaque handle (Track 9-style primitive). The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` validates the port's declared type is Bool; there is no unsafe escape hatch. A raw `PortId` does not typecheck in `BranchArm.condition`; the field type is `BoolPortRef`, not `PortId`. Matching the `ParamRef` / `TransformRef` pattern from DB-9 R2.1 — the handle carries both "is a valid port reference" AND "is Bool-typed," with the relation folded into the type rather than carried by constructor discipline. ✓ no raw `Int`/`NodeId`/`PortId` with comment-level or constructor-level validity; the invariant is carried by the type itself. **Q3 — Duplicated fact.** Does Field A duplicate what's derivable from Field B? @@ -456,9 +456,7 @@ Part 2 (follow-up implementation PR) must satisfy: ## Pre-start gate for Part 2 implementation -**Gate status: CLEARED.** PR #529 (ComposedEffect reshape, session `warm-newt-750`) merged 2026-04-18 as commit `8c7e7acdd`. Part 2 dispatch is structurally unblocked. - -**Original gate rationale (preserved for record):** DB-18's `LinearEffect` consumer delegates to `compose_effects(List) -> CompositionVerdict`. That signature is defined by PR #529's R3. Starting Part 2 against the pre-#529 `compose_effects(...) -> ComposedEffect` signature would have locked DB-18 against a shape being removed — the lens would either (a) build against the doomed `ComposedEffect` and need immediate rework, or (b) anticipate the R3 shape and diverge from main until #529 landed. Both paths burned throughput for no signal. The gate prevented that divergence; #529's merge cleared it. +**Gate status: CLEARED.** Part 2 dispatch is structurally unblocked — `compose_effects(List) -> CompositionVerdict` is the live signature on `main`; DB-18's `LinearEffect` consumer delegates to it without divergence. **STOP-AND-ESCALATE rule for Part 2 dispatch:** @@ -484,27 +482,31 @@ The director chat owns the call on each of these. Silent in-flight patches destr 4. **Does `LinearEffect.ops` need to be `List` instead of `List` to allow mixed nesting?** The worked example C demonstrates the answer: a linear sequence with a branch in the middle lifts to a `BranchEffect` of two linear paths (distributive over composition). No `LinearEffect` with a `WorkflowEffect` in the middle is needed; the structural canonical form is always a variant at the outermost shape. Locked per Q6 (no representation duality) — not open. -5. ~~**Does the substrate need a `WorkflowDeclaration` record to host `WorkflowEffect`?**~~ **CLOSED in R2.** `WorkflowEffect` values live exclusively on user-declared `data` declarations typed `WorkflowEffect`. See §"Authority site for `WorkflowEffect`" above. No new declaration kind needed; `data` is the host. This closes the Q5 single-authority question at Part 1 scope rather than deferring to Part 2. - --- ## Rejected alternatives -**R0 — lens-only, derive `WorkflowEffect` from DAG shape at walk time.** Rejected in §Revision history. Core issue: re-derives workflow structure heuristically from Bind chain / Branch / Loop behavior, violating `feedback_lenses_not_passes` ("heuristic = missing physics"). A workflow's control-flow shape is a fact about the author's intent, not something the compiler should re-infer per-lens. +Each entry names a shape a reader might reasonably propose and states the live reason it stays out of scope. Read these before proposing an alternative shape. + +**Lens-only, derive `WorkflowEffect` from DAG shape at walk time.** Re-derives workflow structure heuristically from Bind chain / Branch / Loop behavior, violating `feedback_lenses_not_passes` ("heuristic = missing physics"). A workflow's control-flow shape is a fact about the author's intent, not something the compiler should re-infer per-lens. + +**Single `WorkflowEffect` coproduct with a kind tag + optional payload fields.** Shape: `WorkflowEffect { kind: Linear | Branch | Loop | Parallel, ops: Option>, arms: Option>, body: Option }`. Admits illegal state combinations (`kind: Linear` with `arms: Some(...)`) — the classic `Bool + Option` pattern `feedback_state_space_vs_behavioral_invariants` names. The four-variant coproduct is the state-space-sound shape. + +**Hierarchical inheritance: `WorkflowEffect` is-a `List` with optional control-flow overlay.** Pre-commits to linear-as-base and overlays branches as a non-structural layer. Stage 2d's branch-wise composition would have to peel back the overlay — the overlay IS the structure. Control-flow is encoded structurally from the start. -**R-alt-A — single `WorkflowEffect` coproduct with a kind tag + optional payload fields.** Shape: `WorkflowEffect { kind: Linear | Branch | Loop | Parallel, ops: Option>, arms: Option>, body: Option }`. Rejected because it admits illegal state combinations (`kind: Linear` with `arms: Some(...)`) — the classic `Bool + Option` pattern `feedback_state_space_vs_behavioral_invariants` names. The four-variant coproduct is the state-space-sound alternative. +**Merge `BranchEffect` and `ParallelEffect` into `ChoiceEffect { kind: Exclusive | Concurrent, arms: NonSingletonList }`.** Re-introduces the `kind + discriminator` pattern Q4's dissolution receipt eliminates. Each variant traces to a distinct categorical operation; compressing two of them under a flag re-creates the compression the coproduct solves. -**R-alt-B — hierarchical inheritance: `WorkflowEffect` is-a `List` with optional control-flow overlay.** Rejected because it pre-commits to linear-as-base and overlays branches as a non-structural layer. Stage 2d's branch-wise composition would have to peel back the overlay — the overlay IS the structure. Better to encode control-flow structurally from the start (R1). +**Drop `ParallelEffect`; add it later via DB-N when Stage 2e ships.** Viable but not chosen: the four-variant shape is locked up front so Stage 2e's DB extends the existing carrier rather than graduating it. A 3-variant initial shape would be an additive refinement and does not regress the Q4 receipt for the remaining three variants — noted as a director-chat judgment call if the 4-variant commit proves too heavy. -**R-alt-C — merge `BranchEffect` and `ParallelEffect` into `ChoiceEffect { kind: Exclusive | Concurrent, arms: NonSingletonList }`.** Rejected because it re-introduces the `kind + discriminator` pattern that Q4's dissolution receipt is supposed to eliminate. The whole point of the four-variant coproduct is that each variant traces to a distinct categorical operation; compressing two of them under a flag re-creates the compression the coproduct solves. +**Raw `PortId` on `BranchArm.condition` with constructor-level validation only.** Relies on a `branch_arm_of(port, body) -> Option` constructor to validate "port is Bool-typed" and requires every lowering site to obey the constructor-only-no-raw-literals convention. That is API-level enforcement: a contributor writing `BranchArm { condition: , body: ... }` directly produces a type-checkable value whose condition port is invalid. The Q4 Pattern-2 receipt leans on the condition field as the load-bearing structural distinction between `BranchEffect` and `ParallelEffect`; if that field admits invalid ports, the receipt degrades to convention-level enforcement and the coproduct dissolution argument collapses. Adopted shape: `BoolPortRef` typed witness — invariant on the type, not the constructor. Additional benefit: `BoolPortRef` is reusable for other Bool-typed-port consumers (e.g., the computation substrate's Branch behavior condition slot) as a Track 9 primitive graduation. -**R-alt-D — drop `ParallelEffect`; add it later via DB-N when Stage 2e ships.** Viable; the user's directive explicitly asked for 4 variants, so this is not the chosen path. If the director chat later prefers a 3-variant initial shape, that is an additive refinement (drop `ParallelEffect`; Stage 2e's DB adds it when it ships) and does not regress the Q4 receipt for the remaining three variants. Noted for director-chat discussion if the 4-variant commit proves too heavy for Part 1. +**Host `WorkflowEffect` on a new `WorkflowDeclaration` kind.** Adds a substrate concept without capability gain — `data` already provides name + type + value hosting, and the type annotation (`WorkflowEffect`) carries the workflow-specificity. Per `feedback_std_over_patterns`, reuse the generic surface rather than enumerating special cases. See §"Authority site for `WorkflowEffect`" for the full argument. -**R-alt-E — raw `PortId` on `BranchArm.condition` with constructor-level validation only (the R1 shape).** Rejected in R2 after the PR #531 ChatGPT + codex reviews. The raw-`PortId` shape relies on `branch_arm_of(port, body) -> Option` to validate "port is Bool-typed" at construction, and requires every lowering site to obey the constructor-only-no-raw-literals convention. That is API-level enforcement: a contributor writing `BranchArm { condition: , body: ... }` directly produces a type-checkable value whose condition port is invalid. The Q4 Pattern-2 receipt leans on the condition field as the load-bearing structural distinction between `BranchEffect` and `ParallelEffect`; if that field admits invalid ports, the receipt degrades to convention-level enforcement and the coproduct dissolution argument collapses. R2 replaces this with a typed-witness (`BoolPortRef`) whose field type itself rejects invalid ports — the invariant is carried by the type, not by the constructor. Additional benefit: the `BoolPortRef` primitive is reusable for other Bool-typed-port consumers (e.g., the computation substrate's Branch behavior condition slot) as a Track 9 primitive graduation. +**Host `WorkflowEffect` as a field on `OperationDeclaration`.** Conflates per-op effect (the operation's own effect shape) with workflow-level composition (how ops compose). They are different kinds of fact and live on different declarations. -**R-alt-F — host `WorkflowEffect` on a new `WorkflowDeclaration` kind.** Rejected in R2 in favor of user-declared `data` typed `WorkflowEffect`. See §"Authority site for `WorkflowEffect`" for the full argument. Short version: a new declaration kind adds substrate concept without capability gain — `data` already provides name + type + value hosting, and the type annotation (`WorkflowEffect`) carries the workflow-specificity. Per `feedback_std_over_patterns`, reuse the generic surface rather than enumerating special cases. +**Sidecar table `Dag.workflows`.** Parallel to the authoring declaration; would require a derivation to stay in sync with the `data` declaration's value slot. Violates Q3 (no duplicated facts) and Q5 (single authority). Sidecars are justified when a fact spans multiple declarations (e.g., `Dag.clusters`); a `WorkflowEffect` is single-declaration-local. -**R-alt-G — defer authority-site selection to Part 2.** Rejected in R2 after the PR #531 codex review BLOCKING finding. Leaving "Stage 2b can read `WorkflowEffect` values from any declaration slot the user happens to declare them on" in the Open Questions is a Q5 single-authority violation: "any slot" is not a single authority. R2 locks the authority site in §"Authority site for `WorkflowEffect`" — user-declared `data` declarations typed `WorkflowEffect`, period. +**Defer authority-site selection to Part 2.** "Stage 2b can read `WorkflowEffect` values from any declaration slot" is a Q5 single-authority violation: "any slot" is not a single authority. The authority site is locked at Part 1 — user-declared `data` declarations typed `WorkflowEffect`. --- From 4ac98599e89725ee842778edd5e74a1acd468df9 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 14:07:01 -0400 Subject: [PATCH 17/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 0560b5e710d..ca40cca59f9 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -187,7 +187,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q6 — Representation duality.** Can the same fact be expressed in two structurally different shapes that comparison treats differently? -- A workflow that is structurally a sequence with a nested branch is expressed *uniquely* as the outermost variant's shape. "Linear with branch in the middle" cannot be represented as `LinearEffect` (because `LinearEffect.ops` is `List`, not `List`); it must be lifted to `BranchEffect { arms: [LinearEffect{A∪B∪D}, LinearEffect{A∪C∪D}] }` — i.e., a branch between two linear paths. This is a structurally-unique canonical form, not a choice between two equivalent representations. A no-op workflow has exactly one canonical form — `LinearEffect { ops: [] }` (the R2 relaxation) — and cannot be spelled another way under the 4-variant coproduct. ✓ no representation duality. +- A workflow that is structurally a sequence with a nested branch is expressed *uniquely* as the outermost variant's shape. "Linear with branch in the middle" cannot be represented as `LinearEffect` (because `LinearEffect.ops` is `List`, not `List`); it must be lifted to `BranchEffect { arms: [LinearEffect{A∪B∪D}, LinearEffect{A∪C∪D}] }` — i.e., a branch between two linear paths. This is a structurally-unique canonical form, not a choice between two equivalent representations. A no-op workflow has exactly one canonical form — `LinearEffect { ops: [] }` — and cannot be spelled another way under the 4-variant coproduct. ✓ no representation duality. All six audit questions stamp cleanly. The full six-question audit is recorded in this section for reviewer-bot verification rather than re-derivation at PR-review time. @@ -210,7 +210,7 @@ All six audit questions stamp cleanly. The full six-question audit is recorded i and read the value via a standard data-declaration value accessor (exact accessor depends on v3's current data-declaration shape; Part 2 binds against the substrate as it stands at Part 2 implementation time). - **Q5 closure.** This is the Q5 single-authority answer: the authority site is the `data` declaration; lowering is the sole producer of `WorkflowEffect` values (by parsing the RHS literal); every reader walks the same discovery function. No alternative hosting paths, no inference-time re-derivation, no lens-time reconstruction. -**Why `data` rather than a new `WorkflowDeclaration` kind.** Both are valid; R2 picks `data` because it is zero-substrate-expansion. The v3 substrate already hosts `data` declarations — adding `WorkflowEffect` as a declarable type in that slot is pure consumer extension of an existing surface. A new `WorkflowDeclaration` kind would be a substrate addition that duplicates what `data` already provides (name + type + value) with a specific-to-workflow type constraint; the specific-to-workflow constraint is already carried by the *type* `WorkflowEffect`. Per `feedback_std_over_patterns` ("types-in-std/ dissolves CX patterns; don't enumerate special cases"), the right move is to reuse the generic `data` surface and let the type annotation carry the specificity. +**Why `data` rather than a new `WorkflowDeclaration` kind.** `data` is chosen because it is zero-substrate-expansion. The v3 substrate already hosts `data` declarations — adding `WorkflowEffect` as a declarable type in that slot is pure consumer extension of an existing surface. A new `WorkflowDeclaration` kind would be a substrate addition that duplicates what `data` already provides (name + type + value) with a specific-to-workflow type constraint; the specific-to-workflow constraint is already carried by the *type* `WorkflowEffect`. Per `feedback_std_over_patterns` ("types-in-std/ dissolves CX patterns; don't enumerate special cases"), the right move is to reuse the generic `data` surface and let the type annotation carry the specificity. **Why not a `Dag.workflows` sidecar.** Sidecar tables are justified when a fact spans multiple declarations (e.g., `Dag.clusters` — an SCC member list spans the cluster's member declarations, so a per-declaration field cannot host it). A `WorkflowEffect` is a single-declaration-local value — it describes one workflow, authored on one declaration. A sidecar would be a parallel cache of facts already stored on the declaration; the consumer could drift from the authority. Per Q3 (no duplicated facts) and Q5 (single authority), the data-declaration value slot is the correct place. @@ -339,7 +339,7 @@ fn analyze_workflow_value(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempoten `WorkflowEffect` never carries a `CompositionVerdict`; `CompositionVerdict` never carries a `WorkflowEffect`. The edge between them is a single lens-level match arm: `LinearEffect { ops } =>` projects `ops` through `compose_effects` to obtain `CompositionVerdict`. No enclosing record pairs them. -This is deliberate. PR #529's R3 removed `ComposedEffect { operations, verdict }` because pairing the input walk and the output verdict in a single record admitted correlated-fields incoherence. DB-18 preserves that lesson: the input structure lives on one axis (`WorkflowEffect`), the output verdict lives on another axis (`CompositionVerdict`), and nothing pairs them. Stage 2b callers that want "the walk AND the verdict" keep their own `WorkflowEffect` in scope and read the lens's `CompositionVerdict` output — two facts kept separate at their natural sites, with no correlation for the type system to enforce. +This is deliberate. PR #529 removed `ComposedEffect { operations, verdict }` because pairing the input walk and the output verdict in a single record admitted correlated-fields incoherence. DB-18 preserves that lesson: the input structure lives on one axis (`WorkflowEffect`), the output verdict lives on another axis (`CompositionVerdict`), and nothing pairs them. Stage 2b callers that want "the walk AND the verdict" keep their own `WorkflowEffect` in scope and read the lens's `CompositionVerdict` output — two facts kept separate at their natural sites, with no correlation for the type system to enforce. ### What DB-18 does NOT touch @@ -476,9 +476,9 @@ The director chat owns the call on each of these. Silent in-flight patches destr 1. **Does `ParallelEffect` need a commutativity witness field in Part 2?** The Pattern 3 algebraic-form analysis argues `ParallelEffect` is a concurrent-product (⊗) requiring algebraic commutativity on the target state. Part 1 does NOT require the witness in the carrier because Stage 2b emits a diagnostic on `ParallelEffect` (no verdict computed). When Stage 2e's parallelism lens binds as a real consumer, it will either (a) extend `ParallelEffect` with `commutativity: AlgebraRef` (or a named-alternative witness carrier) as an additive payload extension, or (b) prove that commutativity is derivable from the workflow's op-level algebra without a substrate field. Decision deferred to Stage 2e design pre-clearance — NOT patched forward in Part 2 implementation. This is an additive extension consistent with the Acceptance-item-1 language (locked VARIANTS + MANDATORY fields; optional additive fields are in-scope for later stages). -2. **Exact post-#529 `WorkflowIdempotencyReport` shape.** The pre-#529 draft shape in `lane2-compile-time-proofs.md` §Stage 2b is `{ idempotent: Bool, breaking_op: String?, evidence_chain: List, diagnostic: Diagnostic? }`. PR #529's pre-start-gate update says this must be replaced with a structural carrier projecting through `CompositionVerdict`. Candidate post-DB-18 shape: `{ verdict: CompositionVerdict, workflow_shape: WorkflowEffect, diagnostic: Diagnostic? }` — but this pairs the algebra output with the input structure in a single record, which is exactly the correlated-fields pattern PR #529 R3 rejected. Alternative: return `CompositionVerdict` OR `Diagnostic` directly from `analyze_workflow_value` (no outer record); the lens's caller pairs it with the `WorkflowEffect` they already hold. The R1 claude-review (2026-04-18) leans to the alternative (no outer record); R2 inherits that lean. Resolve at Part 2 design. +2. **Exact `WorkflowIdempotencyReport` shape.** Must project through `CompositionVerdict` (per PR #529's pre-start-gate update to `lane2-compile-time-proofs.md` §Stage 2b). A candidate shape `{ verdict: CompositionVerdict, workflow_shape: WorkflowEffect, diagnostic: Diagnostic? }` would pair the algebra output with the input structure in a single record — the correlated-fields pattern PR #529 rejected. The alternative is to return `CompositionVerdict` OR `Diagnostic` directly from `analyze_workflow_value` (no outer record); the lens's caller pairs it with the `WorkflowEffect` they already hold. Lean: the no-outer-record alternative. Resolve at Part 2 design. -3. **Does `LoopEffect.body` need a bound carrier in Part 1?** Substrate has `LoopBound = Cardinality { count: PortId } | Descent { cluster: ClusterId }` post-DB-9 R2.1. Stage 2d (symbolic cost) will almost certainly need the bound to compute recursion depth. Part 1 does NOT include a bound field because Stage 2b emits a diagnostic on `LoopEffect` without reading bound info. Part 2 / Stage 2d can add `bound: LoopBound` as an additive extension when its consumer binds — consistent with Acceptance item 1's explicit additive-extension clause (which was tightened in R2 to prevent the doc-coherence gap ChatGPT flagged). If the director chat prefers a single shape-lock including the bound, a R3 revision can add `LoopEffect { body: WorkflowEffect, bound: LoopBound }`; decision is a scope judgment, not a correctness one. +3. **Does `LoopEffect.body` need a bound carrier in Part 1?** Substrate has `LoopBound = Cardinality { count: PortId } | Descent { cluster: ClusterId }`. Stage 2d (symbolic cost) will almost certainly need the bound to compute recursion depth. Part 1 does NOT include a bound field because Stage 2b emits a diagnostic on `LoopEffect` without reading bound info. Part 2 / Stage 2d can add `bound: LoopBound` as an additive extension when its consumer binds — consistent with Acceptance item 1's additive-extension clause. If director chat prefers a single shape-lock including the bound, a future DB revision adds `LoopEffect { body: WorkflowEffect, bound: LoopBound }`; decision is a scope judgment, not a correctness one. 4. **Does `LinearEffect.ops` need to be `List` instead of `List` to allow mixed nesting?** The worked example C demonstrates the answer: a linear sequence with a branch in the middle lifts to a `BranchEffect` of two linear paths (distributive over composition). No `LinearEffect` with a `WorkflowEffect` in the middle is needed; the structural canonical form is always a variant at the outermost shape. Locked per Q6 (no representation duality) — not open. @@ -515,11 +515,11 @@ Each entry names a shape a reader might reasonably propose and states the live r - `feedback_coproduct_dissolution` — Q4 receipt (§"Dissolution receipt" above cites patterns 1–4). - `feedback_substrate_principle_audit` — Q1–Q6 (§"Substrate principle audit" cites all six). - `feedback_state_space_vs_behavioral_invariants` — cardinality invariants via `NonEmptyList` / `NonSingletonList`; rejected alternative R-alt-A. -- `feedback_lenses_not_passes` — rejected alternative R0 (lens-level re-derivation). +- `feedback_lenses_not_passes` — anchor for the lens-level re-derivation rejection (see §Rejected alternatives). - `feedback_no_metadata_markers` — `BranchArm.condition: BoolPortRef` is a typed opaque handle, not a string marker or raw index. - `feedback_fail_closed_discipline` — `report_unsupported_variant` produces a `Diagnostic`, not a silent skip; `bool_port_of` returning `None` must be surfaced as `Diagnostic::BranchConditionNotBool` at the caller (Part 2 Acceptance item 4). - `feedback_std_over_patterns` — authority-site decision reuses `data` rather than inventing `WorkflowDeclaration` (R-alt-F rejection). -- DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. Also: the `ParamRef` / `TransformRef` Track 9 primitive pattern that `BoolPortRef` follows (R2 third primitive graduation). +- DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. Also: the `ParamRef` / `TransformRef` Track 9 primitive pattern that `BoolPortRef` follows (third primitive graduation). - DB-16 (`design-db16-refined-generic-substitution.md`) — worked example of Part 1 design / Part 2 impl split; mirrors DB-18's scope discipline. - PR #529 (`design-composed-effect-reshape.md`) — `CompositionVerdict` authority that DB-18's `LinearEffect` path delegates to. Merged 2026-04-18 as `8c7e7acdd`. - ROADMAP (`../ROADMAP.md` and `../src/v3/ROADMAP.md`) — Lane 2 Stage 2b entry; this DB advances the row from planned lens-only to substrate-carrier + lens per director escalation. Track 9 line 763-772 references for primitive-graduation discipline. From dea9fb4880a5453231c04c88a7e472bcc85e1bb6 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 14:14:06 -0400 Subject: [PATCH 18/29] =?UTF-8?q?docs:=20DB-18=20=E2=80=94=20fix=20Workflo?= =?UTF-8?q?w=20B=20type=20error=20in=20worked=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex review at d7f226a96 (APPROVE, 0 BLOCKING) flagged one non-blocking fix-in-PR-if-easy: Workflow B shows `BrokenBy.first_breaker.shape: IsBreaking(AppendEffect)` but BreakingOperation.shape is typed BreakingShape (not EffectShape), so the outer IsBreaking wrapper is redundant and the example wouldn't typecheck as written. Updated to show AppendEffect directly and added a parenthetical explaining why no wrapper is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design-db18-workflow-effect-carrier.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index ca40cca59f9..6378e93377a 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -395,7 +395,7 @@ IAM.grant(...) // UpsertEffect (idempotent) AuditLog.append(event=...) // AppendEffect (breaking) ``` -Encoded as `LinearEffect { ops: [upsert, exchange, grant, append] }`. Stage 2b calls `compose_effects`, receives `CompositionVerdict::BrokenBy { first_breaker: BreakingOperation { operation_name: "AuditLog.append", shape: IsBreaking(AppendEffect) } }`. Report red with diagnostic naming `AuditLog.append` as the breaker. ✓ — mirrors `lane2-compile-time-proofs.md` §Stage 2b Acceptance fixture 2. +Encoded as `LinearEffect { ops: [upsert, exchange, grant, append] }`. Stage 2b calls `compose_effects`, receives `CompositionVerdict::BrokenBy { first_breaker: BreakingOperation { operation_name: "AuditLog.append", shape: AppendEffect } }`. (`BreakingOperation.shape: BreakingShape` already narrows to the breaking subset — no `IsBreaking` wrapper.) Report red with diagnostic naming `AuditLog.append` as the breaker. ✓ — mirrors `lane2-compile-time-proofs.md` §Stage 2b Acceptance fixture 2. **Workflow C — retry with nested branch (Stage 2b diagnostic).** From bcf89875d40db81907d0f5cf7ded69b170ef6cf3 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:06:50 -0400 Subject: [PATCH 19/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 60 ++++++++++----------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 6378e93377a..d5605e7243d 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -4,7 +4,7 @@ **Design blocker:** DB-18 (new substrate carrier `WorkflowEffect` — a four-variant coproduct describing workflow control-flow shape for effect-algebra composition) **Consumers:** Lane 2 Stage 2b workflow idempotency lens (initial consumer; `LinearEffect`-only scope). Downstream: Stage 2d symbolic cost, Stage 2e parallelism-as-lens, Stage 2f user-declared dimensions. -**Status:** Part 1 design-only — implementation and tests ship in the follow-up Part 2 PR. +**Status:** Design doc describing live state. Part 2 implementation shipped in PR #534 (eager-fox-851): Rust mirrors `WorkflowEffect` / `BranchArm` / `BranchPredicateRef` in `src/v3/compiler/src/dag.rs`, typed constructor `Dag::branch_arm_of`, and analyzer `workflow_idempotency::analyze_workflow`. Part 3 (data-declaration surface for authoring workflows; `.dag`-lens-visibility via reflected `Behavior` realization) is named follow-up work — not shipped today. **Companion:** [design-composed-effect-reshape.md](./design-composed-effect-reshape.md) (PR #529, landed 2026-04-18 as `8c7e7acdd`) reshapes the *output* side of the effect algebra: `ComposedEffect` is removed; `compose_effects(effects: List) -> CompositionVerdict` is the post-reshape algebra. DB-18 adds the *input* side: a typed carrier describing the workflow's control-flow structure above `List`. The two live on orthogonal axes — `WorkflowEffect` is the input structure the lens walks; `CompositionVerdict` is the output the algebra returns. They coexist; neither replaces the other. --- @@ -13,7 +13,7 @@ Stage 2b's pre-DB-18 design (`lane2-compile-time-proofs.md` §Stage 2b) assumed a workflow is a linear `List` — `compose_effects` walks the list, composes effect shapes, emits a verdict. That shape is correct for the narrow case (GCP Secret Manager upsert → STS Exchange → IAM grant, all linear) but the escalation clause in the same section already names the extension: *"if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension."* -DB-18 is that legitimate design extension, moved up-front rather than deferred. The substrate gains **one new reflected coproduct** (`WorkflowEffect`), **one helper record** (`BranchArm`), and **one Track 9-style typed opaque handle** (`BoolPortRef`, the third Track 9 primitive after `ParamRef` and `TransformRef`). The computation substrate is untouched (no new `Behavior` variant, no `LoopBound` change); the effect algebra (`compose_effects`, `CompositionVerdict`) is untouched. The authority site for `WorkflowEffect` values is locked to user-declared `data` declarations typed `WorkflowEffect` — no new declaration kind, no sidecar table. Stage 2b's lens matches on `LinearEffect` only and emits explicit diagnostics on the other three variants, each naming the downstream stage that will consume it. The diagnostic is a first-class `Diagnostic`, not a silent skip — the fail-closed contract from INVARIANTS §C-8 applies. `bool_port_of` (the `BoolPortRef` constructor) returning `None` is a fail-closed boundary at the caller: lowering emits `Diagnostic::BranchConditionNotBool`, never silently absorbs the absence. +DB-18 is that legitimate design extension. The substrate gains **one new coproduct** (`WorkflowEffect`), **one helper record** (`BranchArm`), and **one typed opaque handle** (`BranchPredicateRef`, same pattern as `ParamRef` / `TransformRef`). The authority site for `WorkflowEffect` values is an optional field on the computation-substrate `ValueNode` (`ValueNode.lane2_workflow: Option>`) — one workflow per Value node, keyed by the workflow root's `NodeId`. Accessors: `Dag::try_register_lane2_workflow_effect(root, workflow) -> bool` (populated by lowering, once the data-declaration surface lands in Part 3) and `Dag::lane2_workflow_effect_at(root) -> Option<&WorkflowEffect>` (read by the analyzer). The five-`Behavior` commitment and the effect algebra (`compose_effects`, `CompositionVerdict`) are untouched. Stage 2b's `analyze_workflow` matches on `LinearEffect` only and emits explicit diagnostics on the other three variants via `WorkflowIdempotencyReport::Unsupported { detail: IdempotencyUnsupportedDetail }`. The fail-closed contract from INVARIANTS §C-8 applies at the `Dag::branch_arm_of(root, port, body) -> Option` boundary: the constructor validates the port resolves to `Bool` on the root's graph; `None` is the typed failure that lowering must surface as a `Diagnostic` — never silently absorb. `WorkflowEffect` is the input-structure carrier. `CompositionVerdict` (PR #529) is the output-verdict carrier. They meet at exactly one edge: Stage 2b's lens projects `LinearEffect { ops }` through `compose_effects(ops)` to obtain a `CompositionVerdict`; other `WorkflowEffect` variants produce diagnostics without invoking the algebra. @@ -72,27 +72,27 @@ type WorkflowEffect // dissolution receipt passes at the payload level rather than // requiring a separate discriminator tag. // -// `condition: BoolPortRef` — a typed opaque handle statically +// `condition: BranchPredicateRef` — a typed opaque handle statically // witnessing that the referenced port is Bool-typed. Because -// BoolPortRef has no unsafe constructor (see `type BoolPortRef` +// BranchPredicateRef has no unsafe constructor (see `type BranchPredicateRef` // below), a raw-literal `BranchArm { condition: , ... }` // is not representable: the field type itself rejects non-Bool ports // at the type level. No convention-level constructor discipline is // required. type BranchArm { - condition: BoolPortRef + condition: BranchPredicateRef body: WorkflowEffect } ``` -**Add `BoolPortRef` as a Track 9-style substrate integrity primitive** (`src/v3/std/substrate.dag` — alongside `NonEmptyList`, `NonSingletonList`, `ParamRef`, `TransformRef`; graduates WITH the first consumer per Track 9 discipline): +**Add `BranchPredicateRef` as a Track 9-style substrate integrity primitive** (`src/v3/std/substrate.dag` — alongside `NonEmptyList`, `NonSingletonList`, `ParamRef`, `TransformRef`; graduates WITH the first consumer per Track 9 discipline): ```dag // 🟢 TERMINAL. Typed opaque handle statically witnessing that the // referenced substrate port's declared type is Bool. The sole // constructor `bool_port_of` validates the port's type at // construction time; there is no unsafe escape hatch for producing -// a BoolPortRef around a non-Bool port. +// a BranchPredicateRef around a non-Bool port. // // Track 9 graduation rationale: this is the third Track 9 primitive // (after ParamRef and TransformRef from DB-9 R2.1). It graduates @@ -105,20 +105,20 @@ type BranchArm { // // Second consumer candidate: computation-substrate Branch behavior's // condition slot. If a Track 9 graduation review determines the -// invariant belongs there too, BoolPortRef extends rather than +// invariant belongs there too, BranchPredicateRef extends rather than // duplicating — same primitive, two consumers. Not a Part 1 commit. -type BoolPortRef +type BranchPredicateRef // Sole constructor. Returns None when the port's declared type is // not Bool; callers that need to emit a Diagnostic on None do so // themselves (fail-closed discipline at the caller boundary, not at // the primitive boundary — same pattern as `Dag::param_of`). -fn bool_port_of(dag: Dag, port: PortId) -> BoolPortRef? +fn bool_port_of(dag: Dag, port: PortId) -> BranchPredicateRef? // Accessor. Recover the underlying PortId from the typed handle; // the witness that the port is Bool-typed is carried by the handle, // not by a duplicate inspection. -fn port_of(bool_ref: BoolPortRef) -> PortId +fn port_of(bool_ref: BranchPredicateRef) -> PortId ``` **Fail-closed boundary placement.** `bool_port_of` returning `Option` is the Track 9 primitive's internal-validation contract (matching `param_of`). The primitive does not itself emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. The *lowering path that calls `bool_port_of`* is required to handle `None` by emitting `Diagnostic::BranchConditionNotBool { port, actual_type, span }` per C-8, never by silently absorbing the absence. The primitive's `Option` is internal validation; the caller's `Diagnostic` emission is the fail-closed boundary. Part 2 Acceptance item 4 names this as a review gate — a silent `None` absorption fails Part 2 review. @@ -143,10 +143,10 @@ Per `feedback_coproduct_dissolution`, every new coproduct must pass the four-pat | `LinearEffect` | `LoopEffect` | `List` | `WorkflowEffect` | ✓ list vs single | | `LinearEffect` | `ParallelEffect` | `List` | `NonSingletonList` | ✓ different element types + different cardinality contract | | `BranchEffect` | `LoopEffect` | `NonSingletonList` | `WorkflowEffect` | ✓ list vs single | -| `BranchEffect` | `ParallelEffect` | `NonSingletonList` | `NonSingletonList` | ✓ `BranchArm` ≠ `WorkflowEffect` (BranchArm carries `condition: BoolPortRef` that ParallelEffect branches do not; the typed-witness field is unrepresentable in ParallelEffect's element type) | +| `BranchEffect` | `ParallelEffect` | `NonSingletonList` | `NonSingletonList` | ✓ `BranchArm` ≠ `WorkflowEffect` (BranchArm carries `condition: BranchPredicateRef` that ParallelEffect branches do not; the typed-witness field is unrepresentable in ParallelEffect's element type) | | `LoopEffect` | `ParallelEffect` | `WorkflowEffect` | `NonSingletonList` | ✓ single vs list | -The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonList<...>` at the outer shape and recurse on `WorkflowEffect` at the element level. The distinction is carried by `BranchArm { condition: BoolPortRef, body: WorkflowEffect }` versus a plain `WorkflowEffect`: a branch arm is an arm-gated-by-a-Bool-typed-port, a parallel branch is an unconditioned concurrent workflow. `condition: BoolPortRef` is the load-bearing structural fact, carried by a **typed opaque handle**. `BoolPortRef`'s sole constructor `bool_port_of(dag, port)` returns `None` for non-Bool ports and has no unsafe escape hatch, so the "port is Bool-typed" invariant is witnessed by the type itself rather than by constructor convention. A raw-literal `BranchArm { condition: , body: ... }` is not type-checkable; only `BranchArm { condition: , body: ... }` is, and a `BoolPortRef` can only come from the fail-closed constructor. Pattern 2's dissolution criterion ("same shape, different label") does not apply because the `BranchArm` wrapper carries a structurally required *typed* field `ParallelEffect`'s branches do not. ✓ does not apply. +The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonList<...>` at the outer shape and recurse on `WorkflowEffect` at the element level. The distinction is carried by `BranchArm { condition: BranchPredicateRef, body: WorkflowEffect }` versus a plain `WorkflowEffect`: a branch arm is an arm-gated-by-a-Bool-typed-port, a parallel branch is an unconditioned concurrent workflow. `condition: BranchPredicateRef` is the load-bearing structural fact, carried by a **typed opaque handle**. `BranchPredicateRef`'s sole constructor `bool_port_of(dag, port)` returns `None` for non-Bool ports and has no unsafe escape hatch, so the "port is Bool-typed" invariant is witnessed by the type itself rather than by constructor convention. A raw-literal `BranchArm { condition: , body: ... }` is not type-checkable; only `BranchArm { condition: , body: ... }` is, and a `BranchPredicateRef` can only come from the fail-closed constructor. Pattern 2's dissolution criterion ("same shape, different label") does not apply because the `BranchArm` wrapper carries a structurally required *typed* field `ParallelEffect`'s branches do not. ✓ does not apply. **Pattern 3 — Algebraic-form (traces to intro/elim of algebraic structures).** The four variants trace to four distinct categorical operations on effect algebras: @@ -174,7 +174,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q2 — Index/handle types.** Does a raw `Int` or `NodeId` encode something with a domain restriction? -- `BranchArm.condition: BoolPortRef` is a typed opaque handle (Track 9-style primitive). The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` validates the port's declared type is Bool; there is no unsafe escape hatch. A raw `PortId` does not typecheck in `BranchArm.condition`; the field type is `BoolPortRef`, not `PortId`. Matching the `ParamRef` / `TransformRef` pattern from DB-9 R2.1 — the handle carries both "is a valid port reference" AND "is Bool-typed," with the relation folded into the type rather than carried by constructor discipline. ✓ no raw `Int`/`NodeId`/`PortId` with comment-level or constructor-level validity; the invariant is carried by the type itself. +- `BranchArm.condition: BranchPredicateRef` is a typed opaque handle (Track 9-style primitive). The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BranchPredicateRef?` validates the port's declared type is Bool; there is no unsafe escape hatch. A raw `PortId` does not typecheck in `BranchArm.condition`; the field type is `BranchPredicateRef`, not `PortId`. Matching the `ParamRef` / `TransformRef` pattern from DB-9 R2.1 — the handle carries both "is a valid port reference" AND "is Bool-typed," with the relation folded into the type rather than carried by constructor discipline. ✓ no raw `Int`/`NodeId`/`PortId` with comment-level or constructor-level validity; the invariant is carried by the type itself. **Q3 — Duplicated fact.** Does Field A duplicate what's derivable from Field B? @@ -183,7 +183,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q5 — Construction authority.** Are multiple call sites independently constructing the same fact? - **Authority site for `WorkflowEffect` values.** See §"Authority site for WorkflowEffect" below — `WorkflowEffect` values live exclusively on user-declared `data` declarations whose declared type is structurally `WorkflowEffect`. Exactly one `WorkflowEffect` per qualifying `Declaration`; no sidecar table, no parallel hosting. Lowering is the sole producer; lens and downstream consumers are pure readers. -- **Construction authority for `BranchArm`.** `BranchArm` itself is directly constructible (it is a plain record), but its validity is witnessed by the `condition: BoolPortRef` field type, NOT by a constructor. The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` is the Track 9 primitive; any lowering path that obtains a `BoolPortRef` did so via `bool_port_of` and therefore either (a) emitted a `Diagnostic::BranchConditionNotBool` on `None` or (b) proceeded with a type-level Bool witness. Direct `BranchArm { condition: bool_port_ref, body: ... }` construction is fine by design because the condition field CANNOT carry an invalid port. Raw-literal `BranchArm { condition: , ... }` is a type error. ✓ single authority on the typed-witness construction path; no convention-level escape hatch. +- **Construction authority for `BranchArm`.** `BranchArm` itself is directly constructible (it is a plain record), but its validity is witnessed by the `condition: BranchPredicateRef` field type, NOT by a constructor. The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BranchPredicateRef?` is the Track 9 primitive; any lowering path that obtains a `BranchPredicateRef` did so via `bool_port_of` and therefore either (a) emitted a `Diagnostic::BranchConditionNotBool` on `None` or (b) proceeded with a type-level Bool witness. Direct `BranchArm { condition: bool_port_ref, body: ... }` construction is fine by design because the condition field CANNOT carry an invalid port. Raw-literal `BranchArm { condition: , ... }` is a type error. ✓ single authority on the typed-witness construction path; no convention-level escape hatch. **Q6 — Representation duality.** Can the same fact be expressed in two structurally different shapes that comparison treats differently? @@ -216,9 +216,9 @@ All six audit questions stamp cleanly. The full six-question audit is recorded i **Rejected host alternatives:** see §Rejected alternatives (R-alt-F for `WorkflowDeclaration`; R-alt-G for deferring the host decision). Field-on-`OperationDeclaration` and sidecar-table alternatives are enumerated below alongside the adopted path so newcomers don't re-propose them. -### Source-to-handle contract — how a user-authored condition becomes a `BoolPortRef` +### Source-to-handle contract — how a user-authored condition becomes a `BranchPredicateRef` -The previous subsection locked *where* `WorkflowEffect` values live. This subsection locks *how* a user-authored `BranchArm.condition` reaches its `BoolPortRef` type. Without this rule, Part 2 could invent (a) name-keyed port recovery (violating `feedback_no_metadata_markers`), (b) a raw-id literal escape hatch (violating structural-opacity of substrate handles), or (c) lowering-time synthesis of a bool witness with no source anchor (violating `feedback_declare_facts_dont_derive`). The single legal path is locked below; the three escape hatches are named as rejected so Part 2 cannot silently adopt one. +The previous subsection locked *where* `WorkflowEffect` values live. This subsection locks *how* a user-authored `BranchArm.condition` reaches its `BranchPredicateRef` type. Without this rule, Part 2 could invent (a) name-keyed port recovery (violating `feedback_no_metadata_markers`), (b) a raw-id literal escape hatch (violating structural-opacity of substrate handles), or (c) lowering-time synthesis of a bool witness with no source anchor (violating `feedback_declare_facts_dont_derive`). The single legal path is locked below; the three escape hatches are named as rejected so Part 2 cannot silently adopt one. **Contract (locked, Part 1):** @@ -248,13 +248,13 @@ The previous subsection locked *where* `WorkflowEffect` values live. This subsec 4. On `Some(bool_ref)`: construct `BranchArm { condition: bool_ref, body: }`. 5. On `None` (port's declared type is not Bool): emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` with the span pointing at the source expression (not the surrounding BranchArm or WorkflowEffect). Do NOT construct a `BranchArm` on the `None` path. - This path is the SOLE source → `BoolPortRef` recovery mechanism. No alternative. + This path is the SOLE source → `BranchPredicateRef` recovery mechanism. No alternative. 3. **Rejected alternative source forms:** - **Named-port lookup** (e.g., `condition: port("my_branch_condition")`). Violates `feedback_no_metadata_markers` (string-keyed structural recovery). - - **Raw `NodeId` / `PortId` literal** (e.g., `condition: NodeId(42)`). Violates structural opacity of substrate handles; a `BoolPortRef` must come from `bool_port_of`, not from a user-authored integer. + - **Raw `NodeId` / `PortId` literal** (e.g., `condition: NodeId(42)`). Violates structural opacity of substrate handles; a `BranchPredicateRef` must come from `bool_port_of`, not from a user-authored integer. - **Lowering-time synthesis without source anchor** (e.g., lowering fabricates a bool witness when the user didn't author one). Violates `feedback_declare_facts_dont_derive`. - - **Any path that produces a `BoolPortRef` without going through `bool_port_of`.** `BoolPortRef` has no unsafe constructor; this rejection is enforced by the Track 9 primitive's own shape. + - **Any path that produces a `BranchPredicateRef` without going through `bool_port_of`.** `BranchPredicateRef` has no unsafe constructor; this rejection is enforced by the Track 9 primitive's own shape. 4. **Fail-closed span discipline.** The `Diagnostic::BranchConditionNotBool` span points at the source expression of the condition, not at the BranchArm or WorkflowEffect wrapper. User sees "this specific condition is not Bool-typed" with a pointer to the exact surface form that needs fixing. Part 2's Acceptance item 4 names this explicitly as a Part 2 review gate — a diagnostic pointing at the wrong span (or absent entirely) fails Part 2 review. @@ -348,14 +348,14 @@ This is deliberate. PR #529 removed `ComposedEffect { operations, verdict }` bec - `OperationEffect`, `BreakingOperation`, `CompositionVerdict`, `IdempotencyEvidence`, `ModifierCheck`. Untouched. - The computation substrate (`Behavior`, `LoopBound`, `Cluster`). Untouched. `Behavior` stays at five variants. - The reflected `Dag` record. Untouched. No new sidecar table for workflows (per §"Authority site for `WorkflowEffect`" — data-declaration value slot is the host). -- Existing Track 9 primitives (`NonEmptyList`, `NonSingletonList`, `ParamRef`, `TransformRef`). Untouched by shape change; `BoolPortRef` joins them as a new peer primitive without modifying any existing one. +- Existing Track 9 primitives (`NonEmptyList`, `NonSingletonList`, `ParamRef`, `TransformRef`). Untouched by shape change; `BranchPredicateRef` joins them as a new peer primitive without modifying any existing one. - `WorkflowEffectConcern` (existing record at `src/v3/std/effects.dag:669–673` post-PR #529; was `:565–569` pre-#529). This is a diagnostic-construction helper, not an input carrier; remains as-is. Part 2 may or may not project through it for the `LinearEffect` diagnostic construction. - V2 `dsl/std/effects.dag`. V3-only, per the same scope discipline PR #529 applied. ### What DB-18 DOES add (summary) - `type WorkflowEffect` (new coproduct) and `type BranchArm` (new record) in `src/v3/std/effects.dag`. -- `type BoolPortRef` (new Track 9 typed opaque handle) with `fn bool_port_of` and `fn port_of` in `src/v3/std/substrate.dag`. Third Track 9 primitive after `ParamRef` / `TransformRef`. +- `type BranchPredicateRef` (new Track 9 typed opaque handle) with `fn bool_port_of` and `fn port_of` in `src/v3/std/substrate.dag`. Third Track 9 primitive after `ParamRef` / `TransformRef`. - Authority-site convention: user-declared `data` typed `WorkflowEffect` is the host. No substrate-addition needed — the `data` declaration surface already exists. --- @@ -430,7 +430,7 @@ Stage 2b dispatches on `LoopEffect`, emits diagnostic: *"`LoopEffect` encountere Part 1 (this design doc, no code) is accepted when the design locks: 1. `WorkflowEffect` is a four-variant coproduct (`LinearEffect | BranchEffect | LoopEffect | ParallelEffect`) with the payload shapes in §"Substrate changes." Payload shapes are locked for Part 1 scope; additive-extension fields graduate from §Open questions as downstream stages bind (e.g., `LoopEffect.bound` when Stage 2d consumes, `ParallelEffect.commutativity` when Stage 2e consumes). The Part 1 lock is the set of VARIANTS (four, exactly) and the MANDATORY fields of each variant (listed in §"Substrate changes"); optional additive fields added by later stages do not regress this lock. -2. `BranchArm { condition: BoolPortRef, body: WorkflowEffect }` is the sole structural distinction between `BranchEffect` and `ParallelEffect` payloads. `BoolPortRef` is a Track 9-style typed opaque handle whose sole constructor is `bool_port_of(dag: Dag, port: PortId) -> BoolPortRef?` — raw-literal construction of a `BranchArm` around a non-Bool port is not representable at the type level. The Q4 Pattern-2 distinction is carried by the typed witness, not by constructor discipline. +2. `BranchArm { condition: BranchPredicateRef, body: WorkflowEffect }` is the sole structural distinction between `BranchEffect` and `ParallelEffect` payloads. `BranchPredicateRef` is a Track 9-style typed opaque handle whose sole constructor is `bool_port_of(dag: Dag, port: PortId) -> BranchPredicateRef?` — raw-literal construction of a `BranchArm` around a non-Bool port is not representable at the type level. The Q4 Pattern-2 distinction is carried by the typed witness, not by constructor discipline. 3. Q1–Q6 substrate-principle audit is stamped in-doc; Q4 dissolution receipt is stamped in-doc. Q5 single-authority is resolved by the §"Authority site for WorkflowEffect" section (not deferred). 4. `LinearEffect` is the Stage 2b consumer; the other three variants produce `Diagnostic` via `report_unsupported_variant` with the downstream stage name. Empty `LinearEffect.ops` is the monoidal identity, consumed by `compose_effects([])` = `IdempotentComposition`. 5. `CompositionVerdict` and `WorkflowEffect` coexist on orthogonal axes — no enclosing record pairs them; `LinearEffect`'s dispatch is the sole edge between them. @@ -440,9 +440,9 @@ Part 1 (this design doc, no code) is accepted when the design locks: Part 2 (follow-up implementation PR) must satisfy: -1. `src/v3/std/effects.dag` declares `type WorkflowEffect` and `type BranchArm` per the substrate-changes section (with `LinearEffect.ops: List`, `BranchArm.condition: BoolPortRef`). -2. `src/v3/std/substrate.dag` (or equivalent Track 9 primitive site) declares `type BoolPortRef` + `fn bool_port_of(dag, port) -> BoolPortRef?` + `fn port_of(bool_ref) -> PortId` per §"Add `BoolPortRef` as a Track 9-style substrate integrity primitive." Primitive graduation trigger: DB-18 BranchArm is the first consumer. -3. Rust mirror in `src/v3/compiler/src/dag.rs` (or equivalent sidecar file) declares `WorkflowEffect`, `BranchArm`, and `BoolPortRef` as `enum` and `struct`s; reflection-invariant test (`m2_field_access_binding_test.rs`-style) locks the two sides together. +1. `src/v3/std/effects.dag` declares `type WorkflowEffect` and `type BranchArm` per the substrate-changes section (with `LinearEffect.ops: List`, `BranchArm.condition: BranchPredicateRef`). +2. `src/v3/std/substrate.dag` (or equivalent Track 9 primitive site) declares `type BranchPredicateRef` + `fn bool_port_of(dag, port) -> BranchPredicateRef?` + `fn port_of(bool_ref) -> PortId` per §"Add `BranchPredicateRef` as a Track 9-style substrate integrity primitive." Primitive graduation trigger: DB-18 BranchArm is the first consumer. +3. Rust mirror in `src/v3/compiler/src/dag.rs` (or equivalent sidecar file) declares `WorkflowEffect`, `BranchArm`, and `BranchPredicateRef` as `enum` and `struct`s; reflection-invariant test (`m2_field_access_binding_test.rs`-style) locks the two sides together. 4. **Fail-closed on `bool_port_of` returning `None`, with source-span precision.** Every lowering site that calls `bool_port_of` must emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` on `None` and must NOT construct a `BranchArm` with that port via any alternative path. The `span` must point at the source-level condition expression (not the surrounding BranchArm or WorkflowEffect), per §"Source-to-handle contract" point 4. Silent `None` absorption is a Part 2 review gate — reject any PR that pattern-matches `bool_port_of(...)` result without a Diagnostic emission branch, or whose diagnostic span points at the wrong surface form. 4b. **Single source-to-handle path.** The lowering code that produces a `BranchArm.condition` value must go through exactly one path: expression → sub-DAG → output-port → `bool_port_of`. Part 2 review rejects any PR that introduces (a) a named-port lookup helper for branch conditions, (b) a raw `NodeId` / `PortId` literal parse at the BranchArm-condition site, or (c) a synthesized bool witness without a source-level expression anchor. Per §"Source-to-handle contract" point 3. 5. `src/v3/lenses/idempotency.dag` (new) declares `analyze_workflow(d: Dag, workflow_decl: DeclarationId) -> WorkflowIdempotencyReport` (resolving the declaration's value via the data-declaration accessor) and `analyze_workflow_value(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport`, dispatching per variant. `LinearEffect` calls `compose_effects`; the other three emit `Diagnostic` via `report_unsupported_variant`. @@ -463,7 +463,7 @@ Part 2 (follow-up implementation PR) must satisfy: If Part 2 implementation discovers any of the following, HALT and report to director chat rather than patching forward: - `WorkflowEffect`'s 4-variant shape is insufficient — e.g., a real workflow fixture requires a fifth variant or a variant payload reshape. DB-18 locks the shape; reshape is a DB revision, not an in-flight patch. -- `BoolPortRef` does not distinguish BranchEffect from ParallelEffect structurally in practice (Q4 receipt regresses), OR the `bool_port_of` fail-closed semantics require an escape hatch for some legitimate lowering case. Same rule — reshape the substrate only through a DB revision. +- `BranchPredicateRef` does not distinguish BranchEffect from ParallelEffect structurally in practice (Q4 receipt regresses), OR the `bool_port_of` fail-closed semantics require an escape hatch for some legitimate lowering case. Same rule — reshape the substrate only through a DB revision. - Stage 2b's `analyze_workflow` / `analyze_workflow_value` signature needs to return something other than `WorkflowIdempotencyReport` (e.g., `List` for multi-diagnostic workflows). This is an API refinement, possibly an open question for director-chat clearance; do not silently change the signature. - The authority-site decision (user-declared `data` typed `WorkflowEffect`) turns out to be insufficient — e.g., a real fixture needs WorkflowEffect hosted somewhere `data` cannot reach. DB-18 locks the authority site; changing it is a DB revision, not an in-flight patch. - `bool_port_of` returning `None` is silently absorbed at any lowering site (no `Diagnostic::BranchConditionNotBool` emission). This is a C-8 violation — Part 2 review must reject it. @@ -498,7 +498,7 @@ Each entry names a shape a reader might reasonably propose and states the live r **Drop `ParallelEffect`; add it later via DB-N when Stage 2e ships.** Viable but not chosen: the four-variant shape is locked up front so Stage 2e's DB extends the existing carrier rather than graduating it. A 3-variant initial shape would be an additive refinement and does not regress the Q4 receipt for the remaining three variants — noted as a director-chat judgment call if the 4-variant commit proves too heavy. -**Raw `PortId` on `BranchArm.condition` with constructor-level validation only.** Relies on a `branch_arm_of(port, body) -> Option` constructor to validate "port is Bool-typed" and requires every lowering site to obey the constructor-only-no-raw-literals convention. That is API-level enforcement: a contributor writing `BranchArm { condition: , body: ... }` directly produces a type-checkable value whose condition port is invalid. The Q4 Pattern-2 receipt leans on the condition field as the load-bearing structural distinction between `BranchEffect` and `ParallelEffect`; if that field admits invalid ports, the receipt degrades to convention-level enforcement and the coproduct dissolution argument collapses. Adopted shape: `BoolPortRef` typed witness — invariant on the type, not the constructor. Additional benefit: `BoolPortRef` is reusable for other Bool-typed-port consumers (e.g., the computation substrate's Branch behavior condition slot) as a Track 9 primitive graduation. +**Raw `PortId` on `BranchArm.condition` with constructor-level validation only.** Relies on a `branch_arm_of(port, body) -> Option` constructor to validate "port is Bool-typed" and requires every lowering site to obey the constructor-only-no-raw-literals convention. That is API-level enforcement: a contributor writing `BranchArm { condition: , body: ... }` directly produces a type-checkable value whose condition port is invalid. The Q4 Pattern-2 receipt leans on the condition field as the load-bearing structural distinction between `BranchEffect` and `ParallelEffect`; if that field admits invalid ports, the receipt degrades to convention-level enforcement and the coproduct dissolution argument collapses. Adopted shape: `BranchPredicateRef` typed witness — invariant on the type, not the constructor. Additional benefit: `BranchPredicateRef` is reusable for other Bool-typed-port consumers (e.g., the computation substrate's Branch behavior condition slot) as a Track 9 primitive graduation. **Host `WorkflowEffect` on a new `WorkflowDeclaration` kind.** Adds a substrate concept without capability gain — `data` already provides name + type + value hosting, and the type annotation (`WorkflowEffect`) carries the workflow-specificity. Per `feedback_std_over_patterns`, reuse the generic surface rather than enumerating special cases. See §"Authority site for `WorkflowEffect`" for the full argument. @@ -516,10 +516,10 @@ Each entry names a shape a reader might reasonably propose and states the live r - `feedback_substrate_principle_audit` — Q1–Q6 (§"Substrate principle audit" cites all six). - `feedback_state_space_vs_behavioral_invariants` — cardinality invariants via `NonEmptyList` / `NonSingletonList`; rejected alternative R-alt-A. - `feedback_lenses_not_passes` — anchor for the lens-level re-derivation rejection (see §Rejected alternatives). -- `feedback_no_metadata_markers` — `BranchArm.condition: BoolPortRef` is a typed opaque handle, not a string marker or raw index. +- `feedback_no_metadata_markers` — `BranchArm.condition: BranchPredicateRef` is a typed opaque handle, not a string marker or raw index. - `feedback_fail_closed_discipline` — `report_unsupported_variant` produces a `Diagnostic`, not a silent skip; `bool_port_of` returning `None` must be surfaced as `Diagnostic::BranchConditionNotBool` at the caller (Part 2 Acceptance item 4). - `feedback_std_over_patterns` — authority-site decision reuses `data` rather than inventing `WorkflowDeclaration` (R-alt-F rejection). -- DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. Also: the `ParamRef` / `TransformRef` Track 9 primitive pattern that `BoolPortRef` follows (third primitive graduation). +- DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. Also: the `ParamRef` / `TransformRef` Track 9 primitive pattern that `BranchPredicateRef` follows (third primitive graduation). - DB-16 (`design-db16-refined-generic-substitution.md`) — worked example of Part 1 design / Part 2 impl split; mirrors DB-18's scope discipline. - PR #529 (`design-composed-effect-reshape.md`) — `CompositionVerdict` authority that DB-18's `LinearEffect` path delegates to. Merged 2026-04-18 as `8c7e7acdd`. - ROADMAP (`../ROADMAP.md` and `../src/v3/ROADMAP.md`) — Lane 2 Stage 2b entry; this DB advances the row from planned lens-only to substrate-carrier + lens per director escalation. Track 9 line 763-772 references for primitive-graduation discipline. From c430a774d21d5d1b63b6b956516a2a22adbebef7 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:08:20 -0400 Subject: [PATCH 20/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 69 +++++++++++---------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index d5605e7243d..56b5a10c5cc 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -85,43 +85,44 @@ type BranchArm { } ``` -**Add `BranchPredicateRef` as a Track 9-style substrate integrity primitive** (`src/v3/std/substrate.dag` — alongside `NonEmptyList`, `NonSingletonList`, `ParamRef`, `TransformRef`; graduates WITH the first consumer per Track 9 discipline): +**`BranchPredicateRef` — typed opaque handle** (shipped in PR #534 as `src/v3/compiler/src/dag.rs::BranchPredicateRef`, following the `ParamRef` / `TransformRef` Track 9 pattern from DB-9 R2.1): + +```rust +// Shipped in src/v3/compiler/src/dag.rs. +// Typed opaque handle statically witnessing that the referenced +// substrate port's declared type is Bool. There is no public +// constructor; the only way to produce one is via Dag::branch_arm_of, +// which validates the port and builds the whole BranchArm at once. +pub struct BranchPredicateRef { + port: PortId, +} -```dag -// 🟢 TERMINAL. Typed opaque handle statically witnessing that the -// referenced substrate port's declared type is Bool. The sole -// constructor `bool_port_of` validates the port's type at -// construction time; there is no unsafe escape hatch for producing -// a BranchPredicateRef around a non-Bool port. -// -// Track 9 graduation rationale: this is the third Track 9 primitive -// (after ParamRef and TransformRef from DB-9 R2.1). It graduates -// here because BranchArm's first consumer (DB-18 §BranchArm) needs -// a type-level witness for the Bool-typed-port invariant that -// carries the Q4 dissolution receipt distinguishing BranchEffect -// from ParallelEffect. A raw PortId would push the invariant into -// constructor convention, which is API-level rather than type-level -// enforcement — exactly the pattern Track 9 exists to dissolve. -// -// Second consumer candidate: computation-substrate Branch behavior's -// condition slot. If a Track 9 graduation review determines the -// invariant belongs there too, BranchPredicateRef extends rather than -// duplicating — same primitive, two consumers. Not a Part 1 commit. -type BranchPredicateRef - -// Sole constructor. Returns None when the port's declared type is -// not Bool; callers that need to emit a Diagnostic on None do so -// themselves (fail-closed discipline at the caller boundary, not at -// the primitive boundary — same pattern as `Dag::param_of`). -fn bool_port_of(dag: Dag, port: PortId) -> BranchPredicateRef? - -// Accessor. Recover the underlying PortId from the typed handle; -// the witness that the port is Bool-typed is carried by the handle, -// not by a duplicate inspection. -fn port_of(bool_ref: BranchPredicateRef) -> PortId +impl BranchPredicateRef { + // Accessor — recover the underlying PortId from the typed handle. + // The Bool witness is carried by the type; consumers do not + // re-inspect the port's declared type. + pub fn port_id(self) -> PortId { self.port } +} +``` + +**Sole constructor: `Dag::branch_arm_of`** (shipped in PR #534). + +```rust +impl Dag { + // The only way to produce a BranchArm (and therefore the only way + // to inhabit BranchPredicateRef). Validates that `port` resolves + // to Bool on the graph rooted at `root`; returns None if the + // port's declared type is not Bool. + pub fn branch_arm_of( + &self, + root: NodeId, + port: PortId, + body: WorkflowEffect, + ) -> Option { ... } +} ``` -**Fail-closed boundary placement.** `bool_port_of` returning `Option` is the Track 9 primitive's internal-validation contract (matching `param_of`). The primitive does not itself emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. The *lowering path that calls `bool_port_of`* is required to handle `None` by emitting `Diagnostic::BranchConditionNotBool { port, actual_type, span }` per C-8, never by silently absorbing the absence. The primitive's `Option` is internal validation; the caller's `Diagnostic` emission is the fail-closed boundary. Part 2 Acceptance item 4 names this as a review gate — a silent `None` absorption fails Part 2 review. +**Fail-closed boundary placement.** `Dag::branch_arm_of` returning `Option` is the Track 9 internal-validation contract (matching `param_of`). The constructor itself does not emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. The *caller that constructs a BranchArm* is required to handle `None` by emitting a `Diagnostic` identifying the non-Bool branch condition, never by silently absorbing the absence. Today the only caller is in-Rust test / lowering scaffolding; when Part 3 (data-declaration ingestion) lands, the surface-to-`BranchArm` lowering path inherits the same fail-closed discipline per C-8. **Why this belongs in `std/effects.dag` rather than `std/substrate.dag`.** `WorkflowEffect` is a concept of the effect algebra — it describes input shapes that `compose_effects` (and its downstream peers) dispatch over. The substrate's own declarations (`Dag`, `Declaration`, `Behavior`, `LoopBound`, `Cluster`) describe the *computation and type substrate*; the effect algebra is a lens-writable layer above that substrate. The existing file already hosts `OperationEffect`, `EffectShape`, `IdempotencyEvidence`, `CompositionVerdict` — all effect-algebra types. `WorkflowEffect` joins that family. From 7ebf6850cfab10b7654789d64e0f20b76f35c9e0 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:09:50 -0400 Subject: [PATCH 21/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 135 ++++++++++---------- 1 file changed, 68 insertions(+), 67 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 56b5a10c5cc..e390228822c 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -147,7 +147,7 @@ Per `feedback_coproduct_dissolution`, every new coproduct must pass the four-pat | `BranchEffect` | `ParallelEffect` | `NonSingletonList` | `NonSingletonList` | ✓ `BranchArm` ≠ `WorkflowEffect` (BranchArm carries `condition: BranchPredicateRef` that ParallelEffect branches do not; the typed-witness field is unrepresentable in ParallelEffect's element type) | | `LoopEffect` | `ParallelEffect` | `WorkflowEffect` | `NonSingletonList` | ✓ single vs list | -The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonList<...>` at the outer shape and recurse on `WorkflowEffect` at the element level. The distinction is carried by `BranchArm { condition: BranchPredicateRef, body: WorkflowEffect }` versus a plain `WorkflowEffect`: a branch arm is an arm-gated-by-a-Bool-typed-port, a parallel branch is an unconditioned concurrent workflow. `condition: BranchPredicateRef` is the load-bearing structural fact, carried by a **typed opaque handle**. `BranchPredicateRef`'s sole constructor `bool_port_of(dag, port)` returns `None` for non-Bool ports and has no unsafe escape hatch, so the "port is Bool-typed" invariant is witnessed by the type itself rather than by constructor convention. A raw-literal `BranchArm { condition: , body: ... }` is not type-checkable; only `BranchArm { condition: , body: ... }` is, and a `BranchPredicateRef` can only come from the fail-closed constructor. Pattern 2's dissolution criterion ("same shape, different label") does not apply because the `BranchArm` wrapper carries a structurally required *typed* field `ParallelEffect`'s branches do not. ✓ does not apply. +The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonList<...>` at the outer shape and recurse on `WorkflowEffect` at the element level. The distinction is carried by `BranchArm { condition: BranchPredicateRef, body: WorkflowEffect }` versus a plain `WorkflowEffect`: a branch arm is an arm-gated-by-a-Bool-typed-port, a parallel branch is an unconditioned concurrent workflow. `condition: BranchPredicateRef` is the load-bearing structural fact, carried by a **typed opaque handle**. `BranchPredicateRef`'s sole constructor `Dag::branch_arm_of(dag, port)` returns `None` for non-Bool ports and has no unsafe escape hatch, so the "port is Bool-typed" invariant is witnessed by the type itself rather than by constructor convention. A raw-literal `BranchArm { condition: , body: ... }` is not type-checkable; only `BranchArm { condition: , body: ... }` is, and a `BranchPredicateRef` can only come from the fail-closed constructor. Pattern 2's dissolution criterion ("same shape, different label") does not apply because the `BranchArm` wrapper carries a structurally required *typed* field `ParallelEffect`'s branches do not. ✓ does not apply. **Pattern 3 — Algebraic-form (traces to intro/elim of algebraic structures).** The four variants trace to four distinct categorical operations on effect algebras: @@ -175,7 +175,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q2 — Index/handle types.** Does a raw `Int` or `NodeId` encode something with a domain restriction? -- `BranchArm.condition: BranchPredicateRef` is a typed opaque handle (Track 9-style primitive). The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BranchPredicateRef?` validates the port's declared type is Bool; there is no unsafe escape hatch. A raw `PortId` does not typecheck in `BranchArm.condition`; the field type is `BranchPredicateRef`, not `PortId`. Matching the `ParamRef` / `TransformRef` pattern from DB-9 R2.1 — the handle carries both "is a valid port reference" AND "is Bool-typed," with the relation folded into the type rather than carried by constructor discipline. ✓ no raw `Int`/`NodeId`/`PortId` with comment-level or constructor-level validity; the invariant is carried by the type itself. +- `BranchArm.condition: BranchPredicateRef` is a typed opaque handle (Track 9-style primitive). The sole constructor `Dag::branch_arm_of(dag: Dag, port: PortId) -> BranchPredicateRef?` validates the port's declared type is Bool; there is no unsafe escape hatch. A raw `PortId` does not typecheck in `BranchArm.condition`; the field type is `BranchPredicateRef`, not `PortId`. Matching the `ParamRef` / `TransformRef` pattern from DB-9 R2.1 — the handle carries both "is a valid port reference" AND "is Bool-typed," with the relation folded into the type rather than carried by constructor discipline. ✓ no raw `Int`/`NodeId`/`PortId` with comment-level or constructor-level validity; the invariant is carried by the type itself. **Q3 — Duplicated fact.** Does Field A duplicate what's derivable from Field B? @@ -184,7 +184,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q5 — Construction authority.** Are multiple call sites independently constructing the same fact? - **Authority site for `WorkflowEffect` values.** See §"Authority site for WorkflowEffect" below — `WorkflowEffect` values live exclusively on user-declared `data` declarations whose declared type is structurally `WorkflowEffect`. Exactly one `WorkflowEffect` per qualifying `Declaration`; no sidecar table, no parallel hosting. Lowering is the sole producer; lens and downstream consumers are pure readers. -- **Construction authority for `BranchArm`.** `BranchArm` itself is directly constructible (it is a plain record), but its validity is witnessed by the `condition: BranchPredicateRef` field type, NOT by a constructor. The sole constructor `bool_port_of(dag: Dag, port: PortId) -> BranchPredicateRef?` is the Track 9 primitive; any lowering path that obtains a `BranchPredicateRef` did so via `bool_port_of` and therefore either (a) emitted a `Diagnostic::BranchConditionNotBool` on `None` or (b) proceeded with a type-level Bool witness. Direct `BranchArm { condition: bool_port_ref, body: ... }` construction is fine by design because the condition field CANNOT carry an invalid port. Raw-literal `BranchArm { condition: , ... }` is a type error. ✓ single authority on the typed-witness construction path; no convention-level escape hatch. +- **Construction authority for `BranchArm`.** `BranchArm` itself is directly constructible (it is a plain record), but its validity is witnessed by the `condition: BranchPredicateRef` field type, NOT by a constructor. The sole constructor `Dag::branch_arm_of(dag: Dag, port: PortId) -> BranchPredicateRef?` is the Track 9 primitive; any lowering path that obtains a `BranchPredicateRef` did so via `Dag::branch_arm_of` and therefore either (a) emitted a `Diagnostic::BranchConditionNotBool` on `None` or (b) proceeded with a type-level Bool witness. Direct `BranchArm { condition: bool_port_ref, body: ... }` construction is fine by design because the condition field CANNOT carry an invalid port. Raw-literal `BranchArm { condition: , ... }` is a type error. ✓ single authority on the typed-witness construction path; no convention-level escape hatch. **Q6 — Representation duality.** Can the same fact be expressed in two structurally different shapes that comparison treats differently? @@ -194,36 +194,40 @@ All six audit questions stamp cleanly. The full six-question audit is recorded i ### Authority site for `WorkflowEffect` -`WorkflowEffect` values are authored in exactly one place: a user-declared `data` declaration whose declared type is structurally `WorkflowEffect`. The v3 `data` declaration form (`data : = `) — shipped in `3a.2` per ROADMAP — is the surface the user writes; lowering parses the RHS literal into a `WorkflowEffect` value and stores it on the resulting `Declaration`'s value slot. +A `WorkflowEffect` value is hosted **on the computation-substrate `ValueNode` at the workflow root**, as an optional field: -**Authority contract (locked, Part 1):** +```rust +// src/v3/compiler/src/dag.rs (shipped, PR #534) +pub struct ValueNode { + // ... existing fields ... + pub(crate) lane2_workflow: Option>, +} +``` -- **One `WorkflowEffect` per qualifying `Declaration`.** A `Declaration` whose declared type structurally matches `WorkflowEffect` carries exactly one `WorkflowEffect` value on its value slot. No sidecar table (`Dag.workflows`), no parallel hosting, no multi-workflow-per-declaration. -- **No other hosting site.** `WorkflowEffect` values do NOT appear as fields of `OperationDeclaration`, `ServiceDeclaration`, or any other declaration kind. They do NOT appear as nested fields inside `OperationEffect` or `CompositionVerdict` (the orthogonality guarantee from §"Coexistence"). They do NOT appear in `Dag.nodes` as behavior-level payloads. The only authoritative site is the value slot of a user-declared `data` declaration whose type is `WorkflowEffect`. -- **Lens discovery.** `analyze_workflow` and downstream consumers enumerate qualifying declarations: - ```dag - fn find_workflow_declarations(d: Dag) -> List - // Returns the ID of every Declaration whose declared type is - // structurally WorkflowEffect. Structural match (not name-based) - // so aliased declared types are recovered via the standard - // type-resolution walk. - ``` - and read the value via a standard data-declaration value accessor (exact accessor depends on v3's current data-declaration shape; Part 2 binds against the substrate as it stands at Part 2 implementation time). -- **Q5 closure.** This is the Q5 single-authority answer: the authority site is the `data` declaration; lowering is the sole producer of `WorkflowEffect` values (by parsing the RHS literal); every reader walks the same discovery function. No alternative hosting paths, no inference-time re-derivation, no lens-time reconstruction. +One workflow per Value node, keyed by the workflow-root's `NodeId`. Populated via `Dag::try_register_lane2_workflow_effect(root, workflow) -> bool`, read via `Dag::lane2_workflow_effect_at(root) -> Option<&WorkflowEffect>`. `workflow_idempotency::analyze_workflow` consumes it through that accessor — one graph-local store, no parallel side table. -**Why `data` rather than a new `WorkflowDeclaration` kind.** `data` is chosen because it is zero-substrate-expansion. The v3 substrate already hosts `data` declarations — adding `WorkflowEffect` as a declarable type in that slot is pure consumer extension of an existing surface. A new `WorkflowDeclaration` kind would be a substrate addition that duplicates what `data` already provides (name + type + value) with a specific-to-workflow type constraint; the specific-to-workflow constraint is already carried by the *type* `WorkflowEffect`. Per `feedback_std_over_patterns` ("types-in-std/ dissolves CX patterns; don't enumerate special cases"), the right move is to reuse the generic `data` surface and let the type annotation carry the specificity. +**Authority contract (live, shipped in PR #534):** -**Why not a `Dag.workflows` sidecar.** Sidecar tables are justified when a fact spans multiple declarations (e.g., `Dag.clusters` — an SCC member list spans the cluster's member declarations, so a per-declaration field cannot host it). A `WorkflowEffect` is a single-declaration-local value — it describes one workflow, authored on one declaration. A sidecar would be a parallel cache of facts already stored on the declaration; the consumer could drift from the authority. Per Q3 (no duplicated facts) and Q5 (single authority), the data-declaration value slot is the correct place. +- **One `WorkflowEffect` per workflow-root Value node.** The root is identified by `NodeId`; the `lane2_workflow` field holds at most one `WorkflowEffect`. No `Dag.workflows: List<...>` sidecar; the carrier is attached to the node it describes. +- **Lowering is the sole producer.** `Dag::try_register_lane2_workflow_effect` is the single write path — it writes the Value node's field and returns `false` if the target node isn't a `ValueNode`. Re-registration semantics are the register API's domain (it currently rejects overwrites of an existing value, per its implementation). +- **Readers go through one accessor.** Every consumer reads via `Dag::lane2_workflow_effect_at(root)`; there is no alternative path into the store. +- **Not yet reflected into `.dag` lenses.** Per the shipped code's own comment: *"not part of the reflected `Behavior` surface in `substrate.dag`, so `.dag` lenses cannot read it until a workflow fact is reflected + realized."* This is an explicit Part-3 hole — Lane 2 Stages 2d / 2e / 2f, which are `.dag`-lens consumers, bind only after the reflection lands. Today the analyzer is a Rust consumer. -**Rejected host alternatives:** see §Rejected alternatives (R-alt-F for `WorkflowDeclaration`; R-alt-G for deferring the host decision). Field-on-`OperationDeclaration` and sidecar-table alternatives are enumerated below alongside the adopted path so newcomers don't re-propose them. +**Part 3 follow-up — data-declaration authoring surface.** The eventual end-user surface is a `data my_flow: WorkflowEffect = BranchEffect { arms: [ BranchArm { condition: , body: }, ... ] }` declaration whose lowered computation sub-DAG registers a `WorkflowEffect` via `try_register_lane2_workflow_effect`. That path — source-form parsing, expression-to-port wiring, fail-closed diagnostic on non-Bool conditions — is **not shipped in Part 2**. It is Part 3 work tracked against: -### Source-to-handle contract — how a user-authored condition becomes a `BranchPredicateRef` +- Value-body carrier reflection (so `.dag` lenses can read `ValueNode.lane2_workflow`; required for Lane 2 Stages 2d / 2e / 2f). +- Surface-to-`WorkflowEffect` lowering (the `Dag::branch_arm_of` call site migrates from today's Rust-side scaffolding to lowering under the data-declaration surface). +- Diagnostic wiring for non-Bool branch conditions (lowering calls `Dag::branch_arm_of`, emits a `Diagnostic` identifying the condition source span on `None`). -The previous subsection locked *where* `WorkflowEffect` values live. This subsection locks *how* a user-authored `BranchArm.condition` reaches its `BranchPredicateRef` type. Without this rule, Part 2 could invent (a) name-keyed port recovery (violating `feedback_no_metadata_markers`), (b) a raw-id literal escape hatch (violating structural-opacity of substrate handles), or (c) lowering-time synthesis of a bool witness with no source anchor (violating `feedback_declare_facts_dont_derive`). The single legal path is locked below; the three escape hatches are named as rejected so Part 2 cannot silently adopt one. +**Rejected host alternatives:** see §Rejected alternatives. `Dag.workflows` sidecar table, field-on-`OperationDeclaration`, lens-time reconstruction, new `WorkflowDeclaration` declaration kind — each is enumerated with its rejection reason so newcomers don't re-propose them. -**Contract (locked, Part 1):** +### Source-to-handle contract (Part 3) -1. **Surface form.** A `BranchArm.condition` is authored as an **ordinary Bool-typed expression** in the surface language — identical to the syntax the user writes for any Bool-typed value elsewhere. No DB-18-specific syntax. No port-lookup function. Example: +The `ValueNode.lane2_workflow` authority above specifies *where* the `WorkflowEffect` lives in the substrate today. This subsection specifies *how* a future user-authored `BranchArm.condition` will reach its `BranchPredicateRef` type once the Part 3 data-declaration surface lands. Today, the only consumer is Rust-side scaffolding that constructs `BranchArm`s via `Dag::branch_arm_of` directly (with a NodeId for the graph root, a PortId for the condition, and a pre-constructed `WorkflowEffect` body). + +The locked-ahead contract (to prevent Part 3 from inventing escape hatches): + +1. **Surface form (Part 3 target).** A `BranchArm.condition` will be authored as an **ordinary Bool-typed expression** in the surface language, identical to the syntax for any Bool-typed value elsewhere. No DB-18-specific syntax. No port-lookup function. Example: ``` data my_flow: WorkflowEffect = BranchEffect { @@ -240,26 +244,24 @@ The previous subsection locked *where* `WorkflowEffect` values live. This subsec } ``` - The user writes the condition as an expression; lowering handles the handle-wrapping. - -2. **Lowering path (single authority).** When lowering encounters a `BranchArm.condition` surface-level expression: +2. **Lowering path (Part 3, sole authority).** When lowering encounters a `BranchArm.condition` surface-level expression: 1. Lower the expression into a computation sub-DAG via the standard value-expression → sub-DAG path (no DB-18-specific pipeline). - 2. Take the sub-DAG's root output port. - 3. Apply `bool_port_of(dag, port)`. - 4. On `Some(bool_ref)`: construct `BranchArm { condition: bool_ref, body: }`. - 5. On `None` (port's declared type is not Bool): emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` with the span pointing at the source expression (not the surrounding BranchArm or WorkflowEffect). Do NOT construct a `BranchArm` on the `None` path. + 2. Take the sub-DAG's root output port as a `PortId`, and the enclosing workflow root as a `NodeId`. + 3. Call `Dag::branch_arm_of(root, port, body) -> Option`. + 4. On `Some(arm)`: install `arm` into the enclosing `arms` list. + 5. On `None` (port's declared type is not Bool on the root's graph): emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` with the span pointing at the source expression (not the surrounding `BranchArm` or `WorkflowEffect`). Do NOT construct a `BranchArm` on the `None` path. - This path is the SOLE source → `BranchPredicateRef` recovery mechanism. No alternative. + This is the SOLE source → `BranchArm` recovery mechanism once Part 3 ships. No alternative. -3. **Rejected alternative source forms:** +3. **Rejected alternative source forms (for Part 3):** - **Named-port lookup** (e.g., `condition: port("my_branch_condition")`). Violates `feedback_no_metadata_markers` (string-keyed structural recovery). - - **Raw `NodeId` / `PortId` literal** (e.g., `condition: NodeId(42)`). Violates structural opacity of substrate handles; a `BranchPredicateRef` must come from `bool_port_of`, not from a user-authored integer. + - **Raw `NodeId` / `PortId` literal** (e.g., `condition: NodeId(42)`). Violates structural opacity of substrate handles; a `BranchPredicateRef` must come from `Dag::branch_arm_of`, not from a user-authored integer. - **Lowering-time synthesis without source anchor** (e.g., lowering fabricates a bool witness when the user didn't author one). Violates `feedback_declare_facts_dont_derive`. - - **Any path that produces a `BranchPredicateRef` without going through `bool_port_of`.** `BranchPredicateRef` has no unsafe constructor; this rejection is enforced by the Track 9 primitive's own shape. + - **Any path that produces a `BranchPredicateRef` without going through `Dag::branch_arm_of`.** `BranchPredicateRef` has no public constructor; this rejection is enforced by the Rust type's own visibility — no unsafe escape hatch. -4. **Fail-closed span discipline.** The `Diagnostic::BranchConditionNotBool` span points at the source expression of the condition, not at the BranchArm or WorkflowEffect wrapper. User sees "this specific condition is not Bool-typed" with a pointer to the exact surface form that needs fixing. Part 2's Acceptance item 4 names this explicitly as a Part 2 review gate — a diagnostic pointing at the wrong span (or absent entirely) fails Part 2 review. +4. **Fail-closed span discipline.** The `Diagnostic::BranchConditionNotBool` span must point at the source expression of the condition, not at the `BranchArm` or `WorkflowEffect` wrapper. Part 3 review rejects any PR whose diagnostic span points at the wrong surface form, or whose lowering silently absorbs `None` without a `Diagnostic`. -**Why the expression-based form (and not a dedicated port-reference syntax).** Per `feedback_std_over_patterns` (the same rationale that chose `data` over `WorkflowDeclaration` for the host): reuse existing surface. The user already writes Bool-typed expressions for every other Bool-consuming slot (`if then ... else ...`, refinements `where `, modifier predicates). Adding a DB-18-specific syntax for branch conditions would enumerate a special case. The value-expression → sub-DAG → port → `bool_port_of` path is zero-new-syntax. +**Why the expression-based form (and not a dedicated port-reference syntax) in Part 3.** Per `feedback_std_over_patterns`: reuse existing surface. The user already writes Bool-typed expressions for every other Bool-consuming slot (`if then ... else ...`, refinements `where `, modifier predicates). Adding a DB-18-specific syntax for branch conditions would enumerate a special case. The expression → sub-DAG → port → `Dag::branch_arm_of` path reuses existing machinery. ### Consumer contract — Stage 2b (LinearEffect-only scope) @@ -356,7 +358,7 @@ This is deliberate. PR #529 removed `ComposedEffect { operations, verdict }` bec ### What DB-18 DOES add (summary) - `type WorkflowEffect` (new coproduct) and `type BranchArm` (new record) in `src/v3/std/effects.dag`. -- `type BranchPredicateRef` (new Track 9 typed opaque handle) with `fn bool_port_of` and `fn port_of` in `src/v3/std/substrate.dag`. Third Track 9 primitive after `ParamRef` / `TransformRef`. +- `type BranchPredicateRef` (new Track 9 typed opaque handle) with `fn Dag::branch_arm_of` and `fn port_of` in `src/v3/std/substrate.dag`. Third Track 9 primitive after `ParamRef` / `TransformRef`. - Authority-site convention: user-declared `data` typed `WorkflowEffect` is the host. No substrate-addition needed — the `data` declaration surface already exists. --- @@ -400,26 +402,25 @@ Encoded as `LinearEffect { ops: [upsert, exchange, grant, append] }`. Stage 2b c **Workflow C — retry with nested branch (Stage 2b diagnostic).** -``` -// port_auth_ok and port_auth_failed are PortId values authored by -// lowering. Each is wrapped via bool_port_of(dag, port) before -// landing in BranchArm.condition; a non-Bool port would surface a -// Diagnostic::BranchConditionNotBool on the lowering path and -// prevent BranchArm construction. -LoopEffect { - body: BranchEffect { - arms: [ - BranchArm { - condition: bool_port_of(d, port_auth_ok).unwrap() // Bool witness obtained - body: LinearEffect { ops: [Secret.get] } - } - BranchArm { - condition: bool_port_of(d, port_auth_failed).unwrap() - body: LinearEffect { ops: [STS.exchange, Secret.get] } - } - ] - } -} +```rust +// workflow_root is the NodeId of the root ValueNode on which the +// WorkflowEffect will be registered via Dag::try_register_lane2_workflow_effect. +// port_auth_ok and port_auth_failed are PortId values produced on that graph. +// Dag::branch_arm_of(root, port, body) validates each port resolves to Bool on +// the root's graph and constructs the BranchArm in one step; a non-Bool port +// returns None and the caller is required to emit a Diagnostic identifying +// the condition source (no silent absorption — C-8). +let arm_ok = dag + .branch_arm_of(workflow_root, port_auth_ok, LinearEffect { ops: vec![secret_get] }.into()) + .expect("port_auth_ok resolves to Bool on the workflow graph"); +let arm_failed = dag + .branch_arm_of(workflow_root, port_auth_failed, LinearEffect { ops: vec![sts_exchange, secret_get] }.into()) + .expect("port_auth_failed resolves to Bool on the workflow graph"); + +let workflow = LoopEffect { + body: BranchEffect { arms: non_singleton_list![arm_ok, arm_failed] }.into(), +}; +dag.try_register_lane2_workflow_effect(workflow_root, workflow); ``` Stage 2b dispatches on `LoopEffect`, emits diagnostic: *"`LoopEffect` encountered at workflow root; iterated effect composition requires body idempotency + bound evidence — consumed by Lane 2 Stage 2d (fixpoint bound + body convergence)."* No verdict. ✓ — the diagnostic path from §"Consumer contract" above; the user knows *why* Stage 2b did not verdict and *which stage will*. @@ -431,25 +432,25 @@ Stage 2b dispatches on `LoopEffect`, emits diagnostic: *"`LoopEffect` encountere Part 1 (this design doc, no code) is accepted when the design locks: 1. `WorkflowEffect` is a four-variant coproduct (`LinearEffect | BranchEffect | LoopEffect | ParallelEffect`) with the payload shapes in §"Substrate changes." Payload shapes are locked for Part 1 scope; additive-extension fields graduate from §Open questions as downstream stages bind (e.g., `LoopEffect.bound` when Stage 2d consumes, `ParallelEffect.commutativity` when Stage 2e consumes). The Part 1 lock is the set of VARIANTS (four, exactly) and the MANDATORY fields of each variant (listed in §"Substrate changes"); optional additive fields added by later stages do not regress this lock. -2. `BranchArm { condition: BranchPredicateRef, body: WorkflowEffect }` is the sole structural distinction between `BranchEffect` and `ParallelEffect` payloads. `BranchPredicateRef` is a Track 9-style typed opaque handle whose sole constructor is `bool_port_of(dag: Dag, port: PortId) -> BranchPredicateRef?` — raw-literal construction of a `BranchArm` around a non-Bool port is not representable at the type level. The Q4 Pattern-2 distinction is carried by the typed witness, not by constructor discipline. +2. `BranchArm { condition: BranchPredicateRef, body: WorkflowEffect }` is the sole structural distinction between `BranchEffect` and `ParallelEffect` payloads. `BranchPredicateRef` is a Track 9-style typed opaque handle whose sole constructor is `Dag::branch_arm_of(dag: Dag, port: PortId) -> BranchPredicateRef?` — raw-literal construction of a `BranchArm` around a non-Bool port is not representable at the type level. The Q4 Pattern-2 distinction is carried by the typed witness, not by constructor discipline. 3. Q1–Q6 substrate-principle audit is stamped in-doc; Q4 dissolution receipt is stamped in-doc. Q5 single-authority is resolved by the §"Authority site for WorkflowEffect" section (not deferred). 4. `LinearEffect` is the Stage 2b consumer; the other three variants produce `Diagnostic` via `report_unsupported_variant` with the downstream stage name. Empty `LinearEffect.ops` is the monoidal identity, consumed by `compose_effects([])` = `IdempotentComposition`. 5. `CompositionVerdict` and `WorkflowEffect` coexist on orthogonal axes — no enclosing record pairs them; `LinearEffect`'s dispatch is the sole edge between them. 6. `WorkflowEffect`'s authority site is locked to user-declared `data` declarations typed `WorkflowEffect` (§"Authority site for WorkflowEffect"). No sidecar table; no parallel hosting; no deferred host-selection decision. -7. The source-to-handle contract for `BranchArm.condition` is locked (§"Source-to-handle contract"). Condition is an ordinary Bool-typed expression; lowering runs the standard expression → sub-DAG → port → `bool_port_of` path; fail-closed `Diagnostic::BranchConditionNotBool` on `None`. Named-port lookup, raw-id literals, and source-anchorless synthesis are rejected paths, not Part 2 judgment calls. +7. The source-to-handle contract for `BranchArm.condition` is locked (§"Source-to-handle contract"). Condition is an ordinary Bool-typed expression; lowering runs the standard expression → sub-DAG → port → `Dag::branch_arm_of` path; fail-closed `Diagnostic::BranchConditionNotBool` on `None`. Named-port lookup, raw-id literals, and source-anchorless synthesis are rejected paths, not Part 2 judgment calls. 8. Part 2 pre-start gate: PR #529 merged 2026-04-18 (`8c7e7acdd`), clearing the gate. Part 2 dispatch is structurally unblocked. Part 2 (follow-up implementation PR) must satisfy: 1. `src/v3/std/effects.dag` declares `type WorkflowEffect` and `type BranchArm` per the substrate-changes section (with `LinearEffect.ops: List`, `BranchArm.condition: BranchPredicateRef`). -2. `src/v3/std/substrate.dag` (or equivalent Track 9 primitive site) declares `type BranchPredicateRef` + `fn bool_port_of(dag, port) -> BranchPredicateRef?` + `fn port_of(bool_ref) -> PortId` per §"Add `BranchPredicateRef` as a Track 9-style substrate integrity primitive." Primitive graduation trigger: DB-18 BranchArm is the first consumer. +2. `src/v3/std/substrate.dag` (or equivalent Track 9 primitive site) declares `type BranchPredicateRef` + `fn Dag::branch_arm_of(dag, port) -> BranchPredicateRef?` + `fn port_of(bool_ref) -> PortId` per §"Add `BranchPredicateRef` as a Track 9-style substrate integrity primitive." Primitive graduation trigger: DB-18 BranchArm is the first consumer. 3. Rust mirror in `src/v3/compiler/src/dag.rs` (or equivalent sidecar file) declares `WorkflowEffect`, `BranchArm`, and `BranchPredicateRef` as `enum` and `struct`s; reflection-invariant test (`m2_field_access_binding_test.rs`-style) locks the two sides together. -4. **Fail-closed on `bool_port_of` returning `None`, with source-span precision.** Every lowering site that calls `bool_port_of` must emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` on `None` and must NOT construct a `BranchArm` with that port via any alternative path. The `span` must point at the source-level condition expression (not the surrounding BranchArm or WorkflowEffect), per §"Source-to-handle contract" point 4. Silent `None` absorption is a Part 2 review gate — reject any PR that pattern-matches `bool_port_of(...)` result without a Diagnostic emission branch, or whose diagnostic span points at the wrong surface form. -4b. **Single source-to-handle path.** The lowering code that produces a `BranchArm.condition` value must go through exactly one path: expression → sub-DAG → output-port → `bool_port_of`. Part 2 review rejects any PR that introduces (a) a named-port lookup helper for branch conditions, (b) a raw `NodeId` / `PortId` literal parse at the BranchArm-condition site, or (c) a synthesized bool witness without a source-level expression anchor. Per §"Source-to-handle contract" point 3. +4. **Fail-closed on `Dag::branch_arm_of` returning `None`, with source-span precision.** Every lowering site that calls `Dag::branch_arm_of` must emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` on `None` and must NOT construct a `BranchArm` with that port via any alternative path. The `span` must point at the source-level condition expression (not the surrounding BranchArm or WorkflowEffect), per §"Source-to-handle contract" point 4. Silent `None` absorption is a Part 2 review gate — reject any PR that pattern-matches `Dag::branch_arm_of(...)` result without a Diagnostic emission branch, or whose diagnostic span points at the wrong surface form. +4b. **Single source-to-handle path.** The lowering code that produces a `BranchArm.condition` value must go through exactly one path: expression → sub-DAG → output-port → `Dag::branch_arm_of`. Part 2 review rejects any PR that introduces (a) a named-port lookup helper for branch conditions, (b) a raw `NodeId` / `PortId` literal parse at the BranchArm-condition site, or (c) a synthesized bool witness without a source-level expression anchor. Per §"Source-to-handle contract" point 3. 5. `src/v3/lenses/idempotency.dag` (new) declares `analyze_workflow(d: Dag, workflow_decl: DeclarationId) -> WorkflowIdempotencyReport` (resolving the declaration's value via the data-declaration accessor) and `analyze_workflow_value(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport`, dispatching per variant. `LinearEffect` calls `compose_effects`; the other three emit `Diagnostic` via `report_unsupported_variant`. 6. `WorkflowIdempotencyReport` replaces the pre-#529 `idempotent: Bool + breaking_op: String?` draft shape with a structural carrier projecting through `CompositionVerdict` (per PR #529's pre-start-gate update to `lane2-compile-time-proofs.md` §Stage 2b). Exact shape resolved at Part 2 design (see Open question §2). 7. Stage 2b acceptance fixtures from `lane2-compile-time-proofs.md` §Stage 2b (three fixtures: GCP linear green, terminal AppendEffect red, POST-create keyless failure) pass against the lens-over-`WorkflowEffect` implementation. -8. New fixtures exercising the diagnostic paths: one `BranchEffect` workflow, one `LoopEffect` workflow, one `ParallelEffect` workflow — each producing the documented diagnostic with the named downstream stage. Additionally: one fixture exercising the fail-closed path on `bool_port_of` — a malformed `data` declaration attempting to use a non-Bool port as a branch condition must surface `Diagnostic::BranchConditionNotBool`, not silently construct an invalid `BranchArm`. +8. New fixtures exercising the diagnostic paths: one `BranchEffect` workflow, one `LoopEffect` workflow, one `ParallelEffect` workflow — each producing the documented diagnostic with the named downstream stage. Additionally: one fixture exercising the fail-closed path on `Dag::branch_arm_of` — a malformed `data` declaration attempting to use a non-Bool port as a branch condition must surface `Diagnostic::BranchConditionNotBool`, not silently construct an invalid `BranchArm`. 9. New fixture exercising the empty-`LinearEffect` case: a `LinearEffect { ops: [] }` workflow produces `CompositionVerdict::IdempotentComposition` (the monoidal identity). 10. `src/v3/ROADMAP.md` Lane 2 Stage 2b row updates to `✅ Shipped (DB-18 + lens)` with links to both design docs. @@ -464,10 +465,10 @@ Part 2 (follow-up implementation PR) must satisfy: If Part 2 implementation discovers any of the following, HALT and report to director chat rather than patching forward: - `WorkflowEffect`'s 4-variant shape is insufficient — e.g., a real workflow fixture requires a fifth variant or a variant payload reshape. DB-18 locks the shape; reshape is a DB revision, not an in-flight patch. -- `BranchPredicateRef` does not distinguish BranchEffect from ParallelEffect structurally in practice (Q4 receipt regresses), OR the `bool_port_of` fail-closed semantics require an escape hatch for some legitimate lowering case. Same rule — reshape the substrate only through a DB revision. +- `BranchPredicateRef` does not distinguish BranchEffect from ParallelEffect structurally in practice (Q4 receipt regresses), OR the `Dag::branch_arm_of` fail-closed semantics require an escape hatch for some legitimate lowering case. Same rule — reshape the substrate only through a DB revision. - Stage 2b's `analyze_workflow` / `analyze_workflow_value` signature needs to return something other than `WorkflowIdempotencyReport` (e.g., `List` for multi-diagnostic workflows). This is an API refinement, possibly an open question for director-chat clearance; do not silently change the signature. - The authority-site decision (user-declared `data` typed `WorkflowEffect`) turns out to be insufficient — e.g., a real fixture needs WorkflowEffect hosted somewhere `data` cannot reach. DB-18 locks the authority site; changing it is a DB revision, not an in-flight patch. -- `bool_port_of` returning `None` is silently absorbed at any lowering site (no `Diagnostic::BranchConditionNotBool` emission). This is a C-8 violation — Part 2 review must reject it. +- `Dag::branch_arm_of` returning `None` is silently absorbed at any lowering site (no `Diagnostic::BranchConditionNotBool` emission). This is a C-8 violation — Part 2 review must reject it. The director chat owns the call on each of these. Silent in-flight patches destroy the structural-finding signal the pre-clearance exists to capture (per `phase-plan-2026-04-18.md` §7 "Structural-finding escalation rule"). @@ -518,7 +519,7 @@ Each entry names a shape a reader might reasonably propose and states the live r - `feedback_state_space_vs_behavioral_invariants` — cardinality invariants via `NonEmptyList` / `NonSingletonList`; rejected alternative R-alt-A. - `feedback_lenses_not_passes` — anchor for the lens-level re-derivation rejection (see §Rejected alternatives). - `feedback_no_metadata_markers` — `BranchArm.condition: BranchPredicateRef` is a typed opaque handle, not a string marker or raw index. -- `feedback_fail_closed_discipline` — `report_unsupported_variant` produces a `Diagnostic`, not a silent skip; `bool_port_of` returning `None` must be surfaced as `Diagnostic::BranchConditionNotBool` at the caller (Part 2 Acceptance item 4). +- `feedback_fail_closed_discipline` — `report_unsupported_variant` produces a `Diagnostic`, not a silent skip; `Dag::branch_arm_of` returning `None` must be surfaced as `Diagnostic::BranchConditionNotBool` at the caller (Part 2 Acceptance item 4). - `feedback_std_over_patterns` — authority-site decision reuses `data` rather than inventing `WorkflowDeclaration` (R-alt-F rejection). - DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. Also: the `ParamRef` / `TransformRef` Track 9 primitive pattern that `BranchPredicateRef` follows (third primitive graduation). - DB-16 (`design-db16-refined-generic-substitution.md`) — worked example of Part 1 design / Part 2 impl split; mirrors DB-18's scope discipline. From 1231a8362466e7a009f73457ff88fb993576259c Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:11:20 -0400 Subject: [PATCH 22/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 132 +++++++++----------- 1 file changed, 56 insertions(+), 76 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index e390228822c..384f6f1a5e8 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -265,66 +265,44 @@ The locked-ahead contract (to prevent Part 3 from inventing escape hatches): ### Consumer contract — Stage 2b (LinearEffect-only scope) -Stage 2b's `analyze_workflow` lens walks a `WorkflowEffect` value, dispatching per variant. Only `LinearEffect` yields a `CompositionVerdict`; the other three variants produce explicit diagnostics. Pseudocode shape (not the Part 2 implementation): +Stage 2b's analyzer walks a `WorkflowEffect` value, dispatching per variant. Only `LinearEffect` yields a `CompositionVerdict`; the other three variants produce explicit unsupported reports. The shipped shape in `workflow_idempotency.rs` (PR #534): -```dag -// Caller resolves `workflow_decl` to a WorkflowEffect value via the -// authority-site accessor (§"Authority site for WorkflowEffect"), -// then dispatches per variant. Both entry points coexist: the -// DeclarationId form is natural for "walk the DAG and analyze all -// workflow-typed data declarations"; the direct-WorkflowEffect form -// is natural for unit tests and sub-workflow recursion. -fn analyze_workflow(d: Dag, workflow_decl: DeclarationId) -> WorkflowIdempotencyReport { - let workflow = read_workflow_value(d, workflow_decl) // standard data-value accessor - analyze_workflow_value(d, workflow) -} - -fn analyze_workflow_value(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport { - match workflow { - LinearEffect { ops } => { - // Delegate to the post-PR-#529 algebra. `compose_effects` - // returns `CompositionVerdict` directly; for ops = [] it - // returns IdempotentComposition (the monoidal identity). - let verdict = compose_effects(ops) - WorkflowIdempotencyReport { - verdict: verdict, - diagnostic: match verdict { - IdempotentComposition => None - BrokenBy { first_breaker } => - Some(Diagnostic { - kind: WorkflowNonIdempotent { breaker: first_breaker } - // ... span + fix info - }) +```rust +// src/v3/compiler/src/workflow_idempotency.rs (shipped, PR #534) +pub fn analyze_workflow(dag: &Dag, root: NodeId) -> WorkflowIdempotencyReport { + let Some(workflow) = dag.lane2_workflow_effect_at(root) else { + return WorkflowIdempotencyReport::Unsupported { + detail: IdempotencyUnsupportedDetail::NoWorkflowAtRoot, + }; + }; + match workflow { + WorkflowEffect::Linear { ops } => { + // Delegate to the post-PR-#529 algebra. compose_effects + // returns CompositionVerdict directly; for ops = [] it + // returns IdempotentComposition (the monoidal identity). + let verdict = compose_effects(ops); + WorkflowIdempotencyReport::Linear { verdict } } - } + WorkflowEffect::Branch { .. } => WorkflowIdempotencyReport::Unsupported { + detail: IdempotencyUnsupportedDetail::Branch { + downstream_stage: "Lane 2 Stage 2d (branch-wise composition — symbolic cost)", + }, + }, + WorkflowEffect::Loop { .. } => WorkflowIdempotencyReport::Unsupported { + detail: IdempotencyUnsupportedDetail::Loop { + downstream_stage: "Lane 2 Stage 2d (fixpoint bound + body convergence)", + }, + }, + WorkflowEffect::Parallel { .. } => WorkflowIdempotencyReport::Unsupported { + detail: IdempotencyUnsupportedDetail::Parallel { + downstream_stage: "Lane 2 Stage 2e (parallelism-as-lens + commutativity witness)", + }, + }, } - BranchEffect => - // Stage 2d owns branch composition (max-path / lattice meet - // over arms). Stage 2b emits a diagnostic that names the - // downstream consumer so the user (and CI) can tell the - // difference between "this is broken" and "this isn't scope". - report_unsupported_variant( - variant_name: "BranchEffect", - downstream_stage: "Lane 2 Stage 2d (branch-wise composition — symbolic cost)", - reason: "branch-wise effect composition is not modeled at Stage 2b" - ) - LoopEffect => - report_unsupported_variant( - variant_name: "LoopEffect", - downstream_stage: "Lane 2 Stage 2d (fixpoint bound + body convergence)", - reason: "iterated effect composition requires body idempotency + bound evidence" - ) - ParallelEffect => - report_unsupported_variant( - variant_name: "ParallelEffect", - downstream_stage: "Lane 2 Stage 2e (parallelism-as-lens + commutativity witness)", - reason: "concurrent effect composition requires algebraic commutativity evidence" - ) - } } ``` -`WorkflowIdempotencyReport`'s exact shape after DB-18 + PR #529 reconcile is left to Part 2 design — the current `{ idempotent: Bool, breaking_op: String?, evidence_chain: List, diagnostic: Diagnostic? }` shape in `lane2-compile-time-proofs.md` §Stage 2b is a pre-#529 placeholder and must be replaced with a structural carrier projecting through `CompositionVerdict` (per PR #529's pre-start-gate update; see §"Open questions" below for the exact reconciliation). +`WorkflowIdempotencyReport` is an enum: `Linear { verdict: CompositionVerdict } | Unsupported { detail: IdempotencyUnsupportedDetail }`. `verdict` carries the algebra's output for the linear case; `detail` names which variant + which downstream stage for the other three. Callers that need to re-render the full workflow alongside a verdict keep their own `WorkflowEffect` in scope and read the report — two facts kept separate at their natural sites, with no correlated-fields record (the PR #529 R3 lesson). **Why the other three variants are diagnostics, not silent skips.** Per C-8 (INVARIANTS §Diagnostic severity): every detectable condition is either an error or not a diagnostic. A workflow with a `BranchEffect` root is not an error — it is an error only if Stage 2b were claiming to be exhaustive over all workflow shapes. Stage 2b explicitly is *not* exhaustive; it is linear-only. So the diagnostic is informational-framed-as-error ("this variant is out of Stage 2b's scope; see Stage 2d") rather than "your workflow is broken." Downstream stages (2d, 2e) will subsume these diagnostics when they ship, replacing them with real verdicts. The diagnostic contract is: *name the variant, name the downstream stage that will consume it, name the reason it cannot be verdicted at Stage 2b.* @@ -429,30 +407,32 @@ Stage 2b dispatches on `LoopEffect`, emits diagnostic: *"`LoopEffect` encountere ## Acceptance -Part 1 (this design doc, no code) is accepted when the design locks: +Design-contract items (locked in this doc, independent of shipping-phase): -1. `WorkflowEffect` is a four-variant coproduct (`LinearEffect | BranchEffect | LoopEffect | ParallelEffect`) with the payload shapes in §"Substrate changes." Payload shapes are locked for Part 1 scope; additive-extension fields graduate from §Open questions as downstream stages bind (e.g., `LoopEffect.bound` when Stage 2d consumes, `ParallelEffect.commutativity` when Stage 2e consumes). The Part 1 lock is the set of VARIANTS (four, exactly) and the MANDATORY fields of each variant (listed in §"Substrate changes"); optional additive fields added by later stages do not regress this lock. -2. `BranchArm { condition: BranchPredicateRef, body: WorkflowEffect }` is the sole structural distinction between `BranchEffect` and `ParallelEffect` payloads. `BranchPredicateRef` is a Track 9-style typed opaque handle whose sole constructor is `Dag::branch_arm_of(dag: Dag, port: PortId) -> BranchPredicateRef?` — raw-literal construction of a `BranchArm` around a non-Bool port is not representable at the type level. The Q4 Pattern-2 distinction is carried by the typed witness, not by constructor discipline. -3. Q1–Q6 substrate-principle audit is stamped in-doc; Q4 dissolution receipt is stamped in-doc. Q5 single-authority is resolved by the §"Authority site for WorkflowEffect" section (not deferred). -4. `LinearEffect` is the Stage 2b consumer; the other three variants produce `Diagnostic` via `report_unsupported_variant` with the downstream stage name. Empty `LinearEffect.ops` is the monoidal identity, consumed by `compose_effects([])` = `IdempotentComposition`. +1. `WorkflowEffect` is a four-variant coproduct (`LinearEffect | BranchEffect | LoopEffect | ParallelEffect`) with the payload shapes in §"Substrate changes." The locked invariant is the set of VARIANTS (four, exactly) and the MANDATORY fields of each variant; additive-extension fields graduate as downstream stages bind (e.g., `LoopEffect.bound` when Stage 2d consumes, `ParallelEffect.commutativity` when Stage 2e consumes) and do not regress this lock. +2. `BranchArm { condition: BranchPredicateRef, body: WorkflowEffect }` is the sole structural distinction between `BranchEffect` and `ParallelEffect` payloads. `BranchPredicateRef` has no public constructor — the only way to inhabit it is via `Dag::branch_arm_of(root, port, body) -> Option`, which validates the port resolves to `Bool` on the root's graph and returns the constructed `BranchArm`. Raw-literal construction of a `BranchArm` around a non-Bool port is not representable at the type level. The Q4 Pattern-2 distinction is carried by the typed witness, not by constructor discipline. +3. Q1–Q6 substrate-principle audit is stamped in-doc; Q4 dissolution receipt is stamped in-doc. Q5 single-authority is resolved by the §"Authority site for WorkflowEffect" section. +4. `LinearEffect` is the Stage 2b consumer; the other three variants produce `WorkflowIdempotencyReport::Unsupported` with a variant-specific `IdempotencyUnsupportedDetail`. Empty `LinearEffect.ops` is the monoidal identity, consumed by `compose_effects([])` = `IdempotentComposition`. 5. `CompositionVerdict` and `WorkflowEffect` coexist on orthogonal axes — no enclosing record pairs them; `LinearEffect`'s dispatch is the sole edge between them. -6. `WorkflowEffect`'s authority site is locked to user-declared `data` declarations typed `WorkflowEffect` (§"Authority site for WorkflowEffect"). No sidecar table; no parallel hosting; no deferred host-selection decision. -7. The source-to-handle contract for `BranchArm.condition` is locked (§"Source-to-handle contract"). Condition is an ordinary Bool-typed expression; lowering runs the standard expression → sub-DAG → port → `Dag::branch_arm_of` path; fail-closed `Diagnostic::BranchConditionNotBool` on `None`. Named-port lookup, raw-id literals, and source-anchorless synthesis are rejected paths, not Part 2 judgment calls. -8. Part 2 pre-start gate: PR #529 merged 2026-04-18 (`8c7e7acdd`), clearing the gate. Part 2 dispatch is structurally unblocked. - -Part 2 (follow-up implementation PR) must satisfy: - -1. `src/v3/std/effects.dag` declares `type WorkflowEffect` and `type BranchArm` per the substrate-changes section (with `LinearEffect.ops: List`, `BranchArm.condition: BranchPredicateRef`). -2. `src/v3/std/substrate.dag` (or equivalent Track 9 primitive site) declares `type BranchPredicateRef` + `fn Dag::branch_arm_of(dag, port) -> BranchPredicateRef?` + `fn port_of(bool_ref) -> PortId` per §"Add `BranchPredicateRef` as a Track 9-style substrate integrity primitive." Primitive graduation trigger: DB-18 BranchArm is the first consumer. -3. Rust mirror in `src/v3/compiler/src/dag.rs` (or equivalent sidecar file) declares `WorkflowEffect`, `BranchArm`, and `BranchPredicateRef` as `enum` and `struct`s; reflection-invariant test (`m2_field_access_binding_test.rs`-style) locks the two sides together. -4. **Fail-closed on `Dag::branch_arm_of` returning `None`, with source-span precision.** Every lowering site that calls `Dag::branch_arm_of` must emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` on `None` and must NOT construct a `BranchArm` with that port via any alternative path. The `span` must point at the source-level condition expression (not the surrounding BranchArm or WorkflowEffect), per §"Source-to-handle contract" point 4. Silent `None` absorption is a Part 2 review gate — reject any PR that pattern-matches `Dag::branch_arm_of(...)` result without a Diagnostic emission branch, or whose diagnostic span points at the wrong surface form. -4b. **Single source-to-handle path.** The lowering code that produces a `BranchArm.condition` value must go through exactly one path: expression → sub-DAG → output-port → `Dag::branch_arm_of`. Part 2 review rejects any PR that introduces (a) a named-port lookup helper for branch conditions, (b) a raw `NodeId` / `PortId` literal parse at the BranchArm-condition site, or (c) a synthesized bool witness without a source-level expression anchor. Per §"Source-to-handle contract" point 3. -5. `src/v3/lenses/idempotency.dag` (new) declares `analyze_workflow(d: Dag, workflow_decl: DeclarationId) -> WorkflowIdempotencyReport` (resolving the declaration's value via the data-declaration accessor) and `analyze_workflow_value(d: Dag, workflow: WorkflowEffect) -> WorkflowIdempotencyReport`, dispatching per variant. `LinearEffect` calls `compose_effects`; the other three emit `Diagnostic` via `report_unsupported_variant`. -6. `WorkflowIdempotencyReport` replaces the pre-#529 `idempotent: Bool + breaking_op: String?` draft shape with a structural carrier projecting through `CompositionVerdict` (per PR #529's pre-start-gate update to `lane2-compile-time-proofs.md` §Stage 2b). Exact shape resolved at Part 2 design (see Open question §2). -7. Stage 2b acceptance fixtures from `lane2-compile-time-proofs.md` §Stage 2b (three fixtures: GCP linear green, terminal AppendEffect red, POST-create keyless failure) pass against the lens-over-`WorkflowEffect` implementation. -8. New fixtures exercising the diagnostic paths: one `BranchEffect` workflow, one `LoopEffect` workflow, one `ParallelEffect` workflow — each producing the documented diagnostic with the named downstream stage. Additionally: one fixture exercising the fail-closed path on `Dag::branch_arm_of` — a malformed `data` declaration attempting to use a non-Bool port as a branch condition must surface `Diagnostic::BranchConditionNotBool`, not silently construct an invalid `BranchArm`. -9. New fixture exercising the empty-`LinearEffect` case: a `LinearEffect { ops: [] }` workflow produces `CompositionVerdict::IdempotentComposition` (the monoidal identity). -10. `src/v3/ROADMAP.md` Lane 2 Stage 2b row updates to `✅ Shipped (DB-18 + lens)` with links to both design docs. +6. `WorkflowEffect`'s authority site is `ValueNode.lane2_workflow` on the computation-substrate Value node at the workflow root. Accessors: `Dag::try_register_lane2_workflow_effect` (write) and `Dag::lane2_workflow_effect_at` (read). One workflow per root; no parallel hosting; no `Dag.workflows` sidecar. +7. The source-to-handle contract for user-authored `BranchArm.condition` values (Part 3) is locked: expression → sub-DAG → output port → `Dag::branch_arm_of(root, port, body)`, fail-closed `Diagnostic::BranchConditionNotBool` on `None`. Named-port lookup, raw-id literals, and source-anchorless synthesis are rejected paths, not Part 3 judgment calls. +8. Pre-start gate for Part 2 (computation-substrate mirrors + Rust-side analyzer): PR #529 merged 2026-04-18 (`8c7e7acdd`), clearing the gate. Part 2 shipped in PR #534 (eager-fox-851). + +**Part 2 — shipped in PR #534** (Rust mirrors + typed constructor + analyzer): + +1. `src/v3/compiler/src/dag.rs` declares Rust enum `WorkflowEffect`, struct `BranchArm`, struct `BranchPredicateRef` (with private `port: PortId` field and `port_id()` accessor). Field `ValueNode.lane2_workflow: Option>` hosts the workflow. +2. `Dag::branch_arm_of(root, port, body) -> Option` is the sole `BranchArm` constructor; validates `port` resolves to `Bool` on the graph rooted at `root`. +3. `Dag::try_register_lane2_workflow_effect(root, workflow) -> bool` and `Dag::lane2_workflow_effect_at(root) -> Option<&WorkflowEffect>` are the write / read accessors for the authority site. +4. `src/v3/compiler/src/workflow_idempotency.rs` declares `analyze_workflow(dag, root) -> WorkflowIdempotencyReport` dispatching per variant: `LinearEffect` delegates to `compose_effects` and returns a verdict; the other three variants return `WorkflowIdempotencyReport::Unsupported { detail: IdempotencyUnsupportedDetail }` identifying the variant and its downstream stage. +5. Structural tests cover: GCP-style linear green path; terminal `AppendEffect` red path; fail-closed on non-Bool branch conditions (`Dag::branch_arm_of` returns `None`); empty-`LinearEffect` monoidal-identity case; diagnostic paths for `BranchEffect` / `LoopEffect` / `ParallelEffect`. + +**Part 3 — follow-up (NOT shipped by PR #534):** + +1. **`ValueNode.lane2_workflow` reflection + realization into the `.dag` substrate surface.** Today the field is Rust-only; `.dag` lenses (Lane 2 Stages 2d / 2e / 2f) cannot read it until the reflection lands. Per the shipped code's own comment: *"not part of the reflected `Behavior` surface in `substrate.dag`, so `.dag` lenses cannot read it until a workflow fact is reflected + realized."* +2. **Data-declaration authoring surface: `data my_flow: WorkflowEffect = ...`.** Lowering parses the surface literal, lowers the condition expressions to computation sub-DAGs, calls `Dag::branch_arm_of` for each branch arm, and registers the resulting `WorkflowEffect` via `Dag::try_register_lane2_workflow_effect`. Fail-closed on non-Bool conditions with source-span-precise `Diagnostic::BranchConditionNotBool`. Per §"Source-to-handle contract" above — no named-port lookup, no raw-id literals, no synthesis escape. +3. **ROADMAP update** once Part 3 lands: Lane 2 Stage 2b row → `✅ Shipped (DB-18 Part 2 Rust carrier + Part 3 surface)`; Lane 2 `.dag`-lens stages 2d / 2e / 2f unblocked by the reflection in Part 3 item 1. + +**Pre-start gate for Part 3:** Value-body reflection into `substrate.dag` (Part 3 item 1) must land before the data-declaration surface (Part 3 item 2) can emit `.dag`-visible registrations. The two Part 3 items form a short linear chain — reflection first, then surface. --- From c944eb4d0823efcfd3b039c060655de7eba1bd42 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:13:15 -0400 Subject: [PATCH 23/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 39 +++++++++------------ 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 384f6f1a5e8..b2ada89ba56 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -183,8 +183,8 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q5 — Construction authority.** Are multiple call sites independently constructing the same fact? -- **Authority site for `WorkflowEffect` values.** See §"Authority site for WorkflowEffect" below — `WorkflowEffect` values live exclusively on user-declared `data` declarations whose declared type is structurally `WorkflowEffect`. Exactly one `WorkflowEffect` per qualifying `Declaration`; no sidecar table, no parallel hosting. Lowering is the sole producer; lens and downstream consumers are pure readers. -- **Construction authority for `BranchArm`.** `BranchArm` itself is directly constructible (it is a plain record), but its validity is witnessed by the `condition: BranchPredicateRef` field type, NOT by a constructor. The sole constructor `Dag::branch_arm_of(dag: Dag, port: PortId) -> BranchPredicateRef?` is the Track 9 primitive; any lowering path that obtains a `BranchPredicateRef` did so via `Dag::branch_arm_of` and therefore either (a) emitted a `Diagnostic::BranchConditionNotBool` on `None` or (b) proceeded with a type-level Bool witness. Direct `BranchArm { condition: bool_port_ref, body: ... }` construction is fine by design because the condition field CANNOT carry an invalid port. Raw-literal `BranchArm { condition: , ... }` is a type error. ✓ single authority on the typed-witness construction path; no convention-level escape hatch. +- **Authority site for `WorkflowEffect` values.** `ValueNode.lane2_workflow: Option>` on the computation-substrate Value node at the workflow root. Writes go through `Dag::try_register_lane2_workflow_effect(root, workflow)`; reads go through `Dag::lane2_workflow_effect_at(root)`. Exactly one workflow per root; no sidecar table; no parallel hosting. See §"Authority site for WorkflowEffect" below. +- **Construction authority for `BranchArm`.** `BranchArm` has no safe direct constructor (the `condition: BranchPredicateRef` field has a crate-private inner field). The sole producer is `Dag::branch_arm_of(root, port, body) -> Option`, which validates the port resolves to `Bool` on the root's graph. Any caller that obtains a `BranchArm` went through that validation; `None` is the typed failure the caller must surface as a `Diagnostic`. Raw-literal `BranchArm { condition: , ... }` is a type error. ✓ single authority on the typed-witness construction path; no convention-level escape hatch. **Q6 — Representation duality.** Can the same fact be expressed in two structurally different shapes that comparison treats differently? @@ -436,19 +436,15 @@ Design-contract items (locked in this doc, independent of shipping-phase): --- -## Pre-start gate for Part 2 implementation +## STOP-AND-ESCALATE rules (Part 3 dispatch) -**Gate status: CLEARED.** Part 2 dispatch is structurally unblocked — `compose_effects(List) -> CompositionVerdict` is the live signature on `main`; DB-18's `LinearEffect` consumer delegates to it without divergence. - -**STOP-AND-ESCALATE rule for Part 2 dispatch:** - -If Part 2 implementation discovers any of the following, HALT and report to director chat rather than patching forward: +Part 2 shipped in PR #534; Part 3 (value-body reflection + data-declaration surface) is the next dispatch under DB-18. If Part 3 implementation discovers any of the following, HALT and report to director chat rather than patching forward: - `WorkflowEffect`'s 4-variant shape is insufficient — e.g., a real workflow fixture requires a fifth variant or a variant payload reshape. DB-18 locks the shape; reshape is a DB revision, not an in-flight patch. -- `BranchPredicateRef` does not distinguish BranchEffect from ParallelEffect structurally in practice (Q4 receipt regresses), OR the `Dag::branch_arm_of` fail-closed semantics require an escape hatch for some legitimate lowering case. Same rule — reshape the substrate only through a DB revision. -- Stage 2b's `analyze_workflow` / `analyze_workflow_value` signature needs to return something other than `WorkflowIdempotencyReport` (e.g., `List` for multi-diagnostic workflows). This is an API refinement, possibly an open question for director-chat clearance; do not silently change the signature. -- The authority-site decision (user-declared `data` typed `WorkflowEffect`) turns out to be insufficient — e.g., a real fixture needs WorkflowEffect hosted somewhere `data` cannot reach. DB-18 locks the authority site; changing it is a DB revision, not an in-flight patch. -- `Dag::branch_arm_of` returning `None` is silently absorbed at any lowering site (no `Diagnostic::BranchConditionNotBool` emission). This is a C-8 violation — Part 2 review must reject it. +- `BranchPredicateRef` does not distinguish BranchEffect from ParallelEffect structurally in practice (Q4 receipt regresses), OR `Dag::branch_arm_of` fail-closed semantics require an escape hatch for some legitimate lowering case. Same rule — reshape the substrate only through a DB revision. +- The authority-site decision (`ValueNode.lane2_workflow` keyed by workflow-root `NodeId`) turns out to be insufficient — e.g., a real fixture needs `WorkflowEffect` hosted somewhere the computation-substrate root cannot reach. DB-18 locks the host; changing it is a DB revision, not an in-flight patch. +- `Dag::branch_arm_of` returning `None` is silently absorbed at any lowering site (no `Diagnostic::BranchConditionNotBool` emission). This is a C-8 violation — Part 3 review must reject it. +- The data-declaration surface for authoring `WorkflowEffect` requires a new FieldValue variant (i.e., the current `ValueBody` / `FieldValue` substrate cannot encode the `data my_flow: WorkflowEffect = ...` literal). This is a substrate extension, not a Part 3 patch — escalate before extending. The director chat owns the call on each of these. Silent in-flight patches destroy the structural-finding signal the pre-clearance exists to capture (per `phase-plan-2026-04-18.md` §7 "Structural-finding escalation rule"). @@ -482,13 +478,11 @@ Each entry names a shape a reader might reasonably propose and states the live r **Raw `PortId` on `BranchArm.condition` with constructor-level validation only.** Relies on a `branch_arm_of(port, body) -> Option` constructor to validate "port is Bool-typed" and requires every lowering site to obey the constructor-only-no-raw-literals convention. That is API-level enforcement: a contributor writing `BranchArm { condition: , body: ... }` directly produces a type-checkable value whose condition port is invalid. The Q4 Pattern-2 receipt leans on the condition field as the load-bearing structural distinction between `BranchEffect` and `ParallelEffect`; if that field admits invalid ports, the receipt degrades to convention-level enforcement and the coproduct dissolution argument collapses. Adopted shape: `BranchPredicateRef` typed witness — invariant on the type, not the constructor. Additional benefit: `BranchPredicateRef` is reusable for other Bool-typed-port consumers (e.g., the computation substrate's Branch behavior condition slot) as a Track 9 primitive graduation. -**Host `WorkflowEffect` on a new `WorkflowDeclaration` kind.** Adds a substrate concept without capability gain — `data` already provides name + type + value hosting, and the type annotation (`WorkflowEffect`) carries the workflow-specificity. Per `feedback_std_over_patterns`, reuse the generic surface rather than enumerating special cases. See §"Authority site for `WorkflowEffect`" for the full argument. - -**Host `WorkflowEffect` as a field on `OperationDeclaration`.** Conflates per-op effect (the operation's own effect shape) with workflow-level composition (how ops compose). They are different kinds of fact and live on different declarations. +**Host `WorkflowEffect` on a new `WorkflowDeclaration` kind or as a field on `OperationDeclaration`.** Adds a substrate concept (or conflates workflow composition with per-op effect) without capability gain. The adopted host (`ValueNode.lane2_workflow` on the computation-substrate root) attaches the workflow to the node it describes and reuses the existing `ValueNode` machinery. The Part 3 data-declaration surface for authoring workflows reuses the existing `data` surface with the `WorkflowEffect` type annotation — no new declaration kind, no specific-to-workflow hosting concept. -**Sidecar table `Dag.workflows`.** Parallel to the authoring declaration; would require a derivation to stay in sync with the `data` declaration's value slot. Violates Q3 (no duplicated facts) and Q5 (single authority). Sidecars are justified when a fact spans multiple declarations (e.g., `Dag.clusters`); a `WorkflowEffect` is single-declaration-local. +**Sidecar table `Dag.workflows`.** Parallel to `ValueNode.lane2_workflow`; would require a derivation to stay in sync with the field. Violates Q3 (no duplicated facts) and Q5 (single authority). Sidecars are justified when a fact spans multiple declarations (e.g., `Dag.clusters`); a `WorkflowEffect` is attached to one root node. -**Defer authority-site selection to Part 2.** "Stage 2b can read `WorkflowEffect` values from any declaration slot" is a Q5 single-authority violation: "any slot" is not a single authority. The authority site is locked at Part 1 — user-declared `data` declarations typed `WorkflowEffect`. +**Host `WorkflowEffect` on the declaration's `value_body` (FieldValue tree) directly.** Considered and rejected for the shipped shape because `ValueBody` / `FieldValue` today cannot carry opaque handles like `BranchPredicateRef` — a `FieldValue::Variant { constructor, payload }` literal cannot inhabit a Rust-only opaque type. The Part 3 data-declaration surface compiles the surface literal into a computation sub-DAG, calls `Dag::branch_arm_of` on the resulting ports, and registers the final `WorkflowEffect` on the root's `ValueNode.lane2_workflow` field. This is the single-authority path — the data-declaration surface is the authoring surface; `ValueNode.lane2_workflow` is the authority storage; the two are connected by the Part 3 lowering pipeline. --- @@ -499,13 +493,14 @@ Each entry names a shape a reader might reasonably propose and states the live r - `feedback_state_space_vs_behavioral_invariants` — cardinality invariants via `NonEmptyList` / `NonSingletonList`; rejected alternative R-alt-A. - `feedback_lenses_not_passes` — anchor for the lens-level re-derivation rejection (see §Rejected alternatives). - `feedback_no_metadata_markers` — `BranchArm.condition: BranchPredicateRef` is a typed opaque handle, not a string marker or raw index. -- `feedback_fail_closed_discipline` — `report_unsupported_variant` produces a `Diagnostic`, not a silent skip; `Dag::branch_arm_of` returning `None` must be surfaced as `Diagnostic::BranchConditionNotBool` at the caller (Part 2 Acceptance item 4). -- `feedback_std_over_patterns` — authority-site decision reuses `data` rather than inventing `WorkflowDeclaration` (R-alt-F rejection). -- DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. Also: the `ParamRef` / `TransformRef` Track 9 primitive pattern that `BranchPredicateRef` follows (third primitive graduation). +- `feedback_fail_closed_discipline` — `WorkflowIdempotencyReport::Unsupported` carries a typed `IdempotencyUnsupportedDetail`, not a silent skip; `Dag::branch_arm_of` returning `None` must be surfaced as a `Diagnostic` identifying the non-Bool condition port at the caller. +- `feedback_std_over_patterns` — authority site reuses `ValueNode` rather than introducing a new declaration kind; Part 3 surface reuses `data` rather than introducing a workflow-specific surface. +- DB-9 R2.1 (`design-mutual-recursion-lowering.md`) — worked example of the six-question audit; mirrors DB-18's format. Also: the `ParamRef` / `TransformRef` Track 9 primitive pattern that `BranchPredicateRef` follows. - DB-16 (`design-db16-refined-generic-substitution.md`) — worked example of Part 1 design / Part 2 impl split; mirrors DB-18's scope discipline. - PR #529 (`design-composed-effect-reshape.md`) — `CompositionVerdict` authority that DB-18's `LinearEffect` path delegates to. Merged 2026-04-18 as `8c7e7acdd`. -- ROADMAP (`../ROADMAP.md` and `../src/v3/ROADMAP.md`) — Lane 2 Stage 2b entry; this DB advances the row from planned lens-only to substrate-carrier + lens per director escalation. Track 9 line 763-772 references for primitive-graduation discipline. +- PR #534 (eager-fox-851) — shipped DB-18 Part 2: Rust mirrors in `src/v3/compiler/src/dag.rs`, `Dag::branch_arm_of` constructor, `workflow_idempotency::analyze_workflow` consumer, `ValueNode.lane2_workflow` authority field. Part 3 (reflection + data-declaration surface) is the follow-up dispatch. +- ROADMAP (`../ROADMAP.md` and `../src/v3/ROADMAP.md`) — Lane 2 Stage 2b entry; this DB advances the row to ✅ Shipped for the Rust carrier + analyzer; Part 3 remains tracked follow-up. --- -End of DB-18 Part 1. +End of DB-18. From 74f0e47ffe7461d6e883977b765c97e20ecdc121 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:14:25 -0400 Subject: [PATCH 24/29] =?UTF-8?q?docs:=20DB-18=20=E2=80=94=20remediate=20t?= =?UTF-8?q?o=20live=20state=20(=CE=B2's=20PR=20#534=20shipped;=20Part=203?= =?UTF-8?q?=20deferred)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per director: doc should describe live state, not aspirational state. β's PR #534 (eager-fox-851) shipped DB-18 Part 2 as Rust mirrors on the computation substrate — WorkflowEffect / BranchArm / BranchPredicateRef in dag.rs, Dag::branch_arm_of constructor, ValueNode.lane2_workflow authority field, workflow_idempotency:: analyze_workflow consumer. The data-declaration authoring surface (data my_flow: WorkflowEffect = ...) is NOT shipped; it's Part 3 follow-up. Changes: - Authority site: rewrote §"Authority site for WorkflowEffect" from "user-declared data declarations typed WorkflowEffect" (aspirational) to "ValueNode.lane2_workflow on the computation-substrate Value node at the workflow root" (shipped). Explicitly names Part 3 as reflection + data-declaration surface work. - Naming: renamed BoolPortRef → BranchPredicateRef throughout to match β's shipped Rust type. bool_port_of → Dag::branch_arm_of (which takes root/port/body and builds the full BranchArm rather than a bare predicate ref). - Source-to-handle contract: reframed as Part 3 forward-looking contract. The three escape hatches (named-port lookup, raw-id literal, synthesis) remain explicitly rejected. - Consumer contract: rewrote the analyzer pseudocode as Rust to match β's shipped workflow_idempotency::analyze_workflow signature (takes `(&Dag, NodeId)`, returns WorkflowIdempotencyReport with Linear{verdict} / Unsupported{detail} variants). - Acceptance: split into Part 2 (shipped in #534) and Part 3 (follow-up). Part 3 items explicitly enumerated. - STOP-AND-ESCALATE: reframed as Part 3 dispatch rules; added a rule for the ValueBody/FieldValue encoding question (substrate extension requires escalation, not in-flight patch). - Worked example C: rewrote in Rust using Dag::branch_arm_of + try_register_lane2_workflow_effect to match shipped API. - Rejected alternatives: updated the hosting discussion — the adopted host is ValueNode.lane2_workflow; data-declaration surface reuse (not a new declaration kind) is still the Part 3 authoring surface. - Cross-references: added PR #534 reference (β's shipped impl). - lane2-compile-time-proofs.md §Stage 2b subsection: updated type block to show shipped Rust shape; rewrote the analyzer signature + authority-site prose to match live state; Part 3 follow-up named explicitly. Also addresses the codex BLOCKING at d7f226a96 and dea9fb488 (ValueBody substrate encoding for BoolPortRef — now obviated because the authority site is ValueNode.lane2_workflow, not ValueBody; Part 3 explicitly names the data-declaration-surface-vs-ValueBody question as an escalation trigger rather than an in-flight patch). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design-db18-workflow-effect-carrier.md | 14 +++++++-- docs/lane2-compile-time-proofs.md | 34 ++++++++++----------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index b2ada89ba56..da5746b2c9d 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -335,9 +335,17 @@ This is deliberate. PR #529 removed `ComposedEffect { operations, verdict }` bec ### What DB-18 DOES add (summary) -- `type WorkflowEffect` (new coproduct) and `type BranchArm` (new record) in `src/v3/std/effects.dag`. -- `type BranchPredicateRef` (new Track 9 typed opaque handle) with `fn Dag::branch_arm_of` and `fn port_of` in `src/v3/std/substrate.dag`. Third Track 9 primitive after `ParamRef` / `TransformRef`. -- Authority-site convention: user-declared `data` typed `WorkflowEffect` is the host. No substrate-addition needed — the `data` declaration surface already exists. +**Part 2 — shipped in PR #534 (eager-fox-851):** +- Rust enum `WorkflowEffect` (four-variant coproduct) + struct `BranchArm` + struct `BranchPredicateRef` (private inner field) in `src/v3/compiler/src/dag.rs`. +- Field `ValueNode.lane2_workflow: Option>` — the authority site. +- `Dag::branch_arm_of(root, port, body) -> Option` — sole constructor; validates port is Bool-typed on the root's graph. +- `Dag::try_register_lane2_workflow_effect` + `Dag::lane2_workflow_effect_at` — writer / reader accessors. +- `workflow_idempotency::analyze_workflow` — analyzer: `LinearEffect` → `CompositionVerdict`; other variants → `WorkflowIdempotencyReport::Unsupported`. + +**Part 3 — follow-up (not shipped):** +- Reflection of `ValueNode.lane2_workflow` into the `.dag` `substrate.dag` surface (unblocks Lane 2 Stages 2d / 2e / 2f as `.dag` consumers). +- Data-declaration authoring surface: lowering a `data my_flow: WorkflowEffect = ...` literal into computation-substrate sub-DAGs that call `Dag::branch_arm_of` and `Dag::try_register_lane2_workflow_effect`. +- Fail-closed `Diagnostic::BranchConditionNotBool` wired to the data-declaration lowering path with source-span precision. --- diff --git a/docs/lane2-compile-time-proofs.md b/docs/lane2-compile-time-proofs.md index d3cb8ef9aee..81c1b6025cd 100644 --- a/docs/lane2-compile-time-proofs.md +++ b/docs/lane2-compile-time-proofs.md @@ -102,28 +102,26 @@ Lens reads each operation's declared `idempotent` modifier AND derives from path **Escalation:** if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension. -**Input structure (DB-18) — `WorkflowEffect` substrate carrier.** The escalation clause above named the extension that DB-18 now locks in: workflow control-flow shape is promoted from "something the lens reconstructs from DAG shape at walk time" (a heuristic, which `feedback_lenses_not_passes` rejects) to a first-class substrate carrier `WorkflowEffect` that lowering produces and lenses read structurally. Shape (R2, post-ChatGPT-review and codex-review response): +**Input structure (DB-18) — `WorkflowEffect` substrate carrier.** DB-18 lifts workflow control-flow shape from "something the lens reconstructs from DAG shape at walk time" (a heuristic that `feedback_lenses_not_passes` rejects) to a first-class substrate carrier. Shipped in PR #534 (eager-fox-851) as Rust mirrors in `src/v3/compiler/src/dag.rs`: +```rust +pub enum WorkflowEffect { + Linear { ops: Vec }, // empty = monoidal identity + Branch { arms: NonSingletonList }, + Loop { body: Box }, + Parallel { branches: NonSingletonList> }, +} + +pub struct BranchArm { condition: BranchPredicateRef, body: Box } + +pub struct BranchPredicateRef { port: PortId } // opaque; no public constructor ``` -type WorkflowEffect - = LinearEffect { ops: List } // empty = monoidal identity - | BranchEffect { arms: NonSingletonList } - | LoopEffect { body: WorkflowEffect } - | ParallelEffect { branches: NonSingletonList } - -type BranchArm { condition: BoolPortRef; body: WorkflowEffect } - -// Track 9-style typed opaque handle — third primitive graduation -// after ParamRef / TransformRef (DB-9 R2.1). Sole constructor -// `bool_port_of(dag, port) -> BoolPortRef?` validates the port's -// declared type is Bool; no unsafe escape hatch. Callers emit -// Diagnostic::BranchConditionNotBool on None per C-8. -type BoolPortRef // opaque -``` -Authority site: user-declared `data` declarations typed `WorkflowEffect` (one value per qualifying declaration; no sidecar table, no alternative hosting). Stage 2b is the initial consumer with **`LinearEffect`-only scope**: the lens matches on `LinearEffect` and delegates to `compose_effects(ops) -> CompositionVerdict` (post-PR #529; empty ops returns `IdempotentComposition` — the monoidal identity). The other three variants emit explicit fail-closed diagnostics (C-8) naming the downstream stage that will consume them — `BranchEffect` → Stage 2d branch-wise composition, `LoopEffect` → Stage 2d fixpoint / body convergence, `ParallelEffect` → Stage 2e parallelism-as-lens with commutativity witness. No silent skip; a `WorkflowEffect` the Stage 2b lens cannot verdict produces a `Diagnostic` explaining which variant, which downstream stage, and why. The `analyze_workflow` signature above becomes `fn analyze_workflow(d: Dag, workflow_decl: DeclarationId) -> WorkflowIdempotencyReport` (resolving the data-declaration's value to a `WorkflowEffect`); the NodeId-indexed form in the draft above is the pre-DB-18 sketch. `WorkflowEffect` and `CompositionVerdict` (post-PR #529) coexist on orthogonal axes — `WorkflowEffect` is the input-structure carrier, `CompositionVerdict` is the output-verdict carrier; no enclosing record pairs them (preserving the lesson PR #529 R3 locked). +Authority site: `ValueNode.lane2_workflow: Option>` on the computation-substrate Value node at the workflow root. Writes via `Dag::try_register_lane2_workflow_effect(root, workflow)`; reads via `Dag::lane2_workflow_effect_at(root)`. One workflow per root; no sidecar table; no parallel hosting. Stage 2b's analyzer (`src/v3/compiler/src/workflow_idempotency.rs::analyze_workflow(dag, root)`) walks the stored workflow and dispatches per variant: `LinearEffect` delegates to `compose_effects` and returns `WorkflowIdempotencyReport::Linear { verdict: CompositionVerdict }` (empty `ops` yields `IdempotentComposition` — the monoidal identity); the other three variants return `WorkflowIdempotencyReport::Unsupported { detail: IdempotencyUnsupportedDetail }` naming the downstream stage (`BranchEffect` / `LoopEffect` → Lane 2 Stage 2d; `ParallelEffect` → Lane 2 Stage 2e with commutativity witness). No silent skip; `IdempotencyUnsupportedDetail` carries the variant identity and its downstream consumer so the user (and CI) can distinguish "broken" from "out of scope." + +`BranchPredicateRef`'s sole constructor is `Dag::branch_arm_of(root, port, body) -> Option`, which validates `port` resolves to `Bool` on the root's graph; `None` is the typed failure that callers must surface as a `Diagnostic`. `WorkflowEffect` and `CompositionVerdict` (PR #529) coexist on orthogonal axes — input structure vs output verdict; no enclosing record pairs them (preserving the PR #529 R3 lesson). -Design: [design-db18-workflow-effect-carrier.md](./design-db18-workflow-effect-carrier.md) — Part 1 R2 locks the 4-variant shape (with `LinearEffect.ops: List` allowing the monoidal identity), the typed `BoolPortRef` witness carrying the Q4 dissolution receipt, the authority-site decision (user-declared `data` typed `WorkflowEffect`), and the Q1–Q6 substrate-principle audit. **Part 2 gate cleared** by PR #529's merge (`8c7e7acdd`, 2026-04-18); Part 2 is structurally unblocked. Part 2 ships `type WorkflowEffect` + `type BranchArm` in `src/v3/std/effects.dag`, `type BoolPortRef` + `fn bool_port_of` in `src/v3/std/substrate.dag` (Track 9 graduation), the Rust mirrors in `src/v3/compiler/src/dag.rs`, the lens in `src/v3/lenses/idempotency.dag`, and reconciles `WorkflowIdempotencyReport` with `CompositionVerdict` (see DB-18 §Open questions). +Design: [design-db18-workflow-effect-carrier.md](./design-db18-workflow-effect-carrier.md) — locks the 4-variant shape, typed `BranchPredicateRef` witness, `ValueNode.lane2_workflow` authority site, and the Q1–Q6 substrate-principle audit. **Part 2 shipped in PR #534.** Part 3 follow-up (not yet shipped) covers (a) reflection of `ValueNode.lane2_workflow` into the `.dag` substrate surface so Lane 2 Stages 2d / 2e / 2f `.dag` lenses can read it, and (b) the data-declaration authoring surface `data my_flow: WorkflowEffect = ...` with lowering to `Dag::branch_arm_of` + `Dag::try_register_lane2_workflow_effect` and fail-closed `Diagnostic::BranchConditionNotBool` on non-Bool conditions. **Pre-start gate:** cleared. All Stage 2a follow-ups have landed — `DerivedOpEffect` collapsed into `OperationEffect` (PR #521); `EffectShape` partitioned into `IsIdempotent(IdempotentShape) | IsBreaking(BreakingShape)`; `BrokenBy`'s payload narrowed to `BreakingOperation { shape: BreakingShape }`; `ComposedEffect` removed. `compose_effects(effects: List) -> CompositionVerdict` is the algebra's output. Stage 2b consumes `CompositionVerdict` directly; callers pair it with their own input `List` for diagnostic rendering. The Stage 2b `WorkflowIdempotencyReport` shape above is one layer higher (a lens report with diagnostic), not a duplication of the algebra verdict — when Stage 2b implements it, the report must match on `CompositionVerdict` and project through `BrokenBy.first_breaker.shape: BreakingShape`. The `breaking_op: String?` field in the draft `WorkflowIdempotencyReport` shape above is a design placeholder from before the Stage 2a partition landed; Stage 2b's implementer should replace it with a structural carrier derived from `CompositionVerdict` rather than copy the flat shape. The implementer must not reintroduce a `ComposedEffect`-style record that pairs `CompositionVerdict` with a sibling list field, since that reintroduces the correlated-fields incoherence R3 just removed. From 1b41230c67acec876a96f1ca1731bbc6725a9597 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:29:08 -0400 Subject: [PATCH 25/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index da5746b2c9d..2ce90f08ebb 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -4,7 +4,7 @@ **Design blocker:** DB-18 (new substrate carrier `WorkflowEffect` — a four-variant coproduct describing workflow control-flow shape for effect-algebra composition) **Consumers:** Lane 2 Stage 2b workflow idempotency lens (initial consumer; `LinearEffect`-only scope). Downstream: Stage 2d symbolic cost, Stage 2e parallelism-as-lens, Stage 2f user-declared dimensions. -**Status:** Design doc describing live state. Part 2 implementation shipped in PR #534 (eager-fox-851): Rust mirrors `WorkflowEffect` / `BranchArm` / `BranchPredicateRef` in `src/v3/compiler/src/dag.rs`, typed constructor `Dag::branch_arm_of`, and analyzer `workflow_idempotency::analyze_workflow`. Part 3 (data-declaration surface for authoring workflows; `.dag`-lens-visibility via reflected `Behavior` realization) is named follow-up work — not shipped today. +**Status:** Design doc describing the shape implemented in PR #534 (eager-fox-851, **OPEN**, head `a4dfa2e87`). The implementation is **🟡 SCAFFOLD** per the comment in `src/v3/compiler/src/dag.rs` — the four-variant `WorkflowEffect` is in the tree, but `.dag` lens walkers cannot yet see `lane2_workflow`, so reflection + realization are Part 3 work. This doc tracks #534's head; when #534 merges, the "OPEN" marker here becomes the merge SHA. Part 2 (Rust carrier + analyzer) lives in #534; Part 3 (`.dag` reflection + data-declaration authoring surface) is named follow-up, not yet proposed. **Companion:** [design-composed-effect-reshape.md](./design-composed-effect-reshape.md) (PR #529, landed 2026-04-18 as `8c7e7acdd`) reshapes the *output* side of the effect algebra: `ComposedEffect` is removed; `compose_effects(effects: List) -> CompositionVerdict` is the post-reshape algebra. DB-18 adds the *input* side: a typed carrier describing the workflow's control-flow structure above `List`. The two live on orthogonal axes — `WorkflowEffect` is the input structure the lens walks; `CompositionVerdict` is the output the algebra returns. They coexist; neither replaces the other. --- From 3325bf5fa3e6f1019b625f3bcb97c16ab22f3b90 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:30:38 -0400 Subject: [PATCH 26/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 64 ++++++++------------- 1 file changed, 24 insertions(+), 40 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 2ce90f08ebb..1712eb9a9d0 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -13,7 +13,7 @@ Stage 2b's pre-DB-18 design (`lane2-compile-time-proofs.md` §Stage 2b) assumed a workflow is a linear `List` — `compose_effects` walks the list, composes effect shapes, emits a verdict. That shape is correct for the narrow case (GCP Secret Manager upsert → STS Exchange → IAM grant, all linear) but the escalation clause in the same section already names the extension: *"if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension."* -DB-18 is that legitimate design extension. The substrate gains **one new coproduct** (`WorkflowEffect`), **one helper record** (`BranchArm`), and **one typed opaque handle** (`BranchPredicateRef`, same pattern as `ParamRef` / `TransformRef`). The authority site for `WorkflowEffect` values is an optional field on the computation-substrate `ValueNode` (`ValueNode.lane2_workflow: Option>`) — one workflow per Value node, keyed by the workflow root's `NodeId`. Accessors: `Dag::try_register_lane2_workflow_effect(root, workflow) -> bool` (populated by lowering, once the data-declaration surface lands in Part 3) and `Dag::lane2_workflow_effect_at(root) -> Option<&WorkflowEffect>` (read by the analyzer). The five-`Behavior` commitment and the effect algebra (`compose_effects`, `CompositionVerdict`) are untouched. Stage 2b's `analyze_workflow` matches on `LinearEffect` only and emits explicit diagnostics on the other three variants via `WorkflowIdempotencyReport::Unsupported { detail: IdempotencyUnsupportedDetail }`. The fail-closed contract from INVARIANTS §C-8 applies at the `Dag::branch_arm_of(root, port, body) -> Option` boundary: the constructor validates the port resolves to `Bool` on the root's graph; `None` is the typed failure that lowering must surface as a `Diagnostic` — never silently absorb. +DB-18 is that legitimate design extension. The substrate gains **one new coproduct** (`WorkflowEffect`, 🟡 scaffold), **one helper record** (`BranchArm`), and **one typed opaque handle** (`BranchPredicateRef`, same pattern as `ParamRef` / `TransformRef`). The authority site for `WorkflowEffect` values is an optional `lane2_workflow: Option>` field on both the `Value` and `Bind` computation-substrate behaviors — one workflow per root node, keyed by the root's `NodeId`. Accessors on `Dag`: `try_register_lane2_workflow_effect(root, workflow) -> bool` (writer; installs on either `Value` or `Bind`) and `lane2_workflow_effect_at(root) -> Option<&WorkflowEffect>` (reader; pattern-matches both variants). The five-`Behavior` commitment and the effect algebra (`compose_effects`, `CompositionVerdict`) are untouched. Stage 2b's `analyze_workflow` matches on `LinearEffect` only and emits explicit diagnostics on the other three variants via `WorkflowIdempotencyReport::Unsupported { detail: IdempotencyUnsupportedDetail }`. The fail-closed contract from INVARIANTS §C-8 applies at the `Dag::branch_arm_of(&self, port, body) -> Option` boundary: the method validates the port resolves to `Bool` on the graph; `None` is the typed failure the caller must surface as a `Diagnostic` — never silently absorb. `WorkflowEffect` is the input-structure carrier. `CompositionVerdict` (PR #529) is the output-verdict carrier. They meet at exactly one edge: Stage 2b's lens projects `LinearEffect { ops }` through `compose_effects(ops)` to obtain a `CompositionVerdict`; other `WorkflowEffect` variants produce diagnostics without invoking the algebra. @@ -53,46 +53,39 @@ DB-18 is that legitimate design extension. The substrate gains **one new coprodu // variant traces to a distinct algebraic operation and the coproduct // is structurally irreducible. // -// Recursive composition: BranchEffect, LoopEffect, and ParallelEffect -// each carry sub-WorkflowEffect fields, so arbitrary nesting composes. -// LinearEffect terminates the recursion at a (possibly empty) list of -// OperationEffect values — the same carrier compose_effects consumes -// post-PR-#529. The empty case is the monoidal identity. +// 🟡 SCAFFOLD. Four-variant workflow sum aligned with effects.dag. +// Stage 2b consumes only LinearEffect today; the other three variants +// are structurally present for round-trip parity but produce +// WorkflowIdempotencyReport::Unsupported until their consumer stages +// (2d / 2e) bind. Dissolution trigger: all four variants have live +// consumers, at which point the 🟡 marker graduates to 🟢. type WorkflowEffect - = LinearEffect { ops: List } + = LinearEffect { ops: NonEmptyList } | BranchEffect { arms: NonSingletonList } | LoopEffect { body: WorkflowEffect } | ParallelEffect { branches: NonSingletonList } -// 🟢 TERMINAL. A single arm of a BranchEffect — the Bool-typed -// condition port witnessing "this arm is taken" plus the workflow -// executed when the condition holds. Separating condition from -// workflow makes the payload structurally distinct from -// ParallelEffect's NonSingletonList, so the Q4 -// dissolution receipt passes at the payload level rather than -// requiring a separate discriminator tag. -// -// `condition: BranchPredicateRef` — a typed opaque handle statically -// witnessing that the referenced port is Bool-typed. Because -// BranchPredicateRef has no unsafe constructor (see `type BranchPredicateRef` -// below), a raw-literal `BranchArm { condition: , ... }` -// is not representable: the field type itself rejects non-Bool ports -// at the type level. No convention-level constructor discipline is -// required. +// A single arm of a BranchEffect. condition carries a typed Bool +// witness (BranchPredicateRef below) that ParallelEffect's plain +// WorkflowEffect branches do not — the Q4 Pattern 2 dissolution +// distinguisher. No public constructor; the only way to produce a +// BranchArm is Dag::branch_arm_of, which validates the predicate +// port. type BranchArm { condition: BranchPredicateRef body: WorkflowEffect } ``` -**`BranchPredicateRef` — typed opaque handle** (shipped in PR #534 as `src/v3/compiler/src/dag.rs::BranchPredicateRef`, following the `ParamRef` / `TransformRef` Track 9 pattern from DB-9 R2.1): +**`BranchPredicateRef` — typed opaque handle** in `src/v3/compiler/src/dag.rs`, following the `ParamRef` / `TransformRef` Track 9 pattern from DB-9 R2.1: ```rust -// Shipped in src/v3/compiler/src/dag.rs. +// src/v3/compiler/src/dag.rs (PR #534, open). // Typed opaque handle statically witnessing that the referenced -// substrate port's declared type is Bool. There is no public -// constructor; the only way to produce one is via Dag::branch_arm_of, -// which validates the port and builds the whole BranchArm at once. +// substrate port's declared type is Bool. The inner field is +// crate-private; there is no public constructor. The only way to +// produce one is via Dag::branch_arm_of, which validates the port +// and builds the whole BranchArm at once. pub struct BranchPredicateRef { port: PortId, } @@ -105,30 +98,21 @@ impl BranchPredicateRef { } ``` -**Sole constructor: `Dag::branch_arm_of`** (shipped in PR #534). +**Sole constructor: `Dag::branch_arm_of(&self, port, body) -> Option`.** Validates that `port` resolves to `Bool` on the graph and builds the `BranchArm` in one step; returns `None` if the port's declared type is not Bool. The method takes the port and the body — no `root` argument; port-type resolution uses the `Dag`'s own graph state. ```rust impl Dag { - // The only way to produce a BranchArm (and therefore the only way - // to inhabit BranchPredicateRef). Validates that `port` resolves - // to Bool on the graph rooted at `root`; returns None if the - // port's declared type is not Bool. pub fn branch_arm_of( &self, - root: NodeId, port: PortId, body: WorkflowEffect, - ) -> Option { ... } + ) -> Option { /* checks port resolves to Bool */ } } ``` -**Fail-closed boundary placement.** `Dag::branch_arm_of` returning `Option` is the Track 9 internal-validation contract (matching `param_of`). The constructor itself does not emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. The *caller that constructs a BranchArm* is required to handle `None` by emitting a `Diagnostic` identifying the non-Bool branch condition, never by silently absorbing the absence. Today the only caller is in-Rust test / lowering scaffolding; when Part 3 (data-declaration ingestion) lands, the surface-to-`BranchArm` lowering path inherits the same fail-closed discipline per C-8. - -**Why this belongs in `std/effects.dag` rather than `std/substrate.dag`.** `WorkflowEffect` is a concept of the effect algebra — it describes input shapes that `compose_effects` (and its downstream peers) dispatch over. The substrate's own declarations (`Dag`, `Declaration`, `Behavior`, `LoopBound`, `Cluster`) describe the *computation and type substrate*; the effect algebra is a lens-writable layer above that substrate. The existing file already hosts `OperationEffect`, `EffectShape`, `IdempotencyEvidence`, `CompositionVerdict` — all effect-algebra types. `WorkflowEffect` joins that family. - -**No reflected `type Dag` changes.** `WorkflowEffect` values live inside user code (as fields of workflow declarations) and inside lens output, not as a sidecar table on `Dag`. The authority for "what workflow does this call site model?" is the user's declaration; the lens walks declarations and reads their `WorkflowEffect`-typed fields. No new `Dag.workflows: List<...>` field is needed (cf. DB-9 R2.1 `Dag.clusters` sidecar which is necessary because clusters span multiple declarations; a `WorkflowEffect` is a single declaration-local value). +**Fail-closed boundary placement.** `Dag::branch_arm_of` returning `Option` is the Track 9 internal-validation contract (matching `param_of`). The method itself does not emit a `Diagnostic` — emitting diagnostics is not the responsibility of a typed-handle primitive. The *caller that constructs a BranchArm* is required to handle `None` by emitting a `Diagnostic` identifying the non-Bool branch condition, never by silently absorbing the absence. Today the only callers are in-Rust tests and scaffolding; when Part 3 (data-declaration ingestion) lands, the surface-to-`BranchArm` lowering path inherits the same fail-closed discipline per C-8. -**No Rust mirror yet.** Because Part 1 is design-only, no `src/v3/compiler/src/dag.rs` change is proposed here. Part 2 mirrors `WorkflowEffect` and `BranchArm` in `dag.rs` under the same reflection-invariant check (`m2_field_access_binding_test.rs`) that DB-9 R2.1 used for `Cluster` / `MemberDescent` / `IntraClusterCall`. The single-authority discipline from DB-9 R2.1 carries over: lowering writes `WorkflowEffect` values; lenses and inference are pure readers. +**Why `WorkflowEffect` is NOT reflected into `std/effects.dag` today.** The `.dag` substrate carries types that `.dag`-authored lenses can walk. `WorkflowEffect` is still Rust-only: the shipped `ValueNode.lane2_workflow` comment explicitly flags *"not part of the reflected `Behavior` surface in `substrate.dag`, so `.dag` lenses cannot read it until a workflow fact is reflected + realized."* Reflection is Part 3 work (and, once landed, graduates the 🟡 scaffold to 🟢 terminal once all four variants have consumers). Until then, the carrier is a Rust mirror consumed by a Rust analyzer (`workflow_idempotency::analyze_workflow`). ### Dissolution receipt — `WorkflowEffect` coproduct (four-pattern check) From 907a8b012f8ef17f58729c382718cf4ce3cb8d33 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:32:07 -0400 Subject: [PATCH 27/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 119 +++++++++++++------- 1 file changed, 79 insertions(+), 40 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 1712eb9a9d0..65d4e9014ec 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -23,7 +23,7 @@ DB-18 is that legitimate design extension. The substrate gains **one new coprodu 1. **`CompositionVerdict` authority preserved.** Post-PR #529, `compose_effects(List) -> CompositionVerdict` is the sole effect-algebra output. DB-18 does not introduce a parallel verdict carrier. `LinearEffect`'s lens delegates to `compose_effects`; other variants do not compose at Stage 2b. 2. **Five-behavior computation substrate untouched.** `Behavior = Value | Transform | Branch | Loop | Bind` remains at five variants. `WorkflowEffect` lives in the *type substrate* (declarations), not the computation substrate. Parallel lens tests (`test_thesis_five_behavior_variants`-style) stay green by construction. -3. **Bounded kernel invariant.** `WorkflowEffect` is recursive through its own variant fields; the recursion terminates at `LinearEffect.ops: List` (no further `WorkflowEffect` children; empty list is a well-defined leaf). Finite by construction because the substrate is a DAG (no cycles in `WorkflowEffect`-typed edges). +3. **Bounded kernel invariant.** `WorkflowEffect` is recursive through its own variant fields; the recursion terminates at `LinearEffect.ops: NonEmptyList` (no further `WorkflowEffect` children; list has ≥1 element by type shape). Finite by construction because the substrate is a DAG (no cycles in `WorkflowEffect`-typed edges). 4. **Illegal states unrepresentable at the type level.** Cardinality invariants use Track 9 primitives (`NonEmptyList`, `NonSingletonList`). No `List` with a "lowering guarantees ≥1" comment; no `Bool + Option` pairs that admit contradictions. 5. **Fail-closed at diagnostic boundaries (INVARIANTS §C-8).** Every branch that does not lead to a `CompositionVerdict` produces a `Diagnostic` explaining which variant was encountered, which downstream stage consumes it, and — if the user can reshape the workflow into a `LinearEffect` form — how. No `Option`, no `Result`, no silent skip. 6. **Stage 2a follow-up authority preserved.** The `CompositionVerdict`/`BreakingOperation`/`IdempotentShape`/`BreakingShape` partitioning landed in PR #529 is consumed by DB-18's `LinearEffect`-path without modification. DB-18 does not reshape `EffectShape`, does not re-materialize `ComposedEffect`, does not pair `CompositionVerdict` with a sibling list field (which would reintroduce the correlated-fields incoherence PR #529 dissolved). @@ -178,36 +178,69 @@ All six audit questions stamp cleanly. The full six-question audit is recorded i ### Authority site for `WorkflowEffect` -A `WorkflowEffect` value is hosted **on the computation-substrate `ValueNode` at the workflow root**, as an optional field: +A `WorkflowEffect` value is hosted **on the computation-substrate Value or Bind behavior at the workflow root**, as an optional field (same name on both): ```rust -// src/v3/compiler/src/dag.rs (shipped, PR #534) +// src/v3/compiler/src/dag.rs (PR #534, open) pub struct ValueNode { // ... existing fields ... pub(crate) lane2_workflow: Option>, } + +pub struct BindNode { + // ... existing fields ... + pub(crate) lane2_workflow: Option>, +} +``` + +`Dag` accessors pattern-match both behaviors: + +```rust +impl Dag { + pub fn try_register_lane2_workflow_effect( + &mut self, + root: NodeId, + workflow: WorkflowEffect, + ) -> bool { + match self.nodes.get_mut(root.index()) { + Some(Behavior::Value(v)) => { v.lane2_workflow = Some(Box::new(workflow)); true } + Some(Behavior::Bind(b)) => { b.lane2_workflow = Some(Box::new(workflow)); true } + _ => false, // other Behavior kinds cannot host a WorkflowEffect + } + } + + pub fn lane2_workflow_effect_at(&self, root: NodeId) -> Option<&WorkflowEffect> { + match self.nodes.get(root.index())? { + Behavior::Value(v) => v.lane2_workflow.as_deref(), + Behavior::Bind(b) => b.lane2_workflow.as_deref(), + _ => None, + } + } +} ``` -One workflow per Value node, keyed by the workflow-root's `NodeId`. Populated via `Dag::try_register_lane2_workflow_effect(root, workflow) -> bool`, read via `Dag::lane2_workflow_effect_at(root) -> Option<&WorkflowEffect>`. `workflow_idempotency::analyze_workflow` consumes it through that accessor — one graph-local store, no parallel side table. +One workflow per root node, keyed by `NodeId`. `workflow_idempotency::analyze_workflow` consumes through `lane2_workflow_effect_at` — one graph-local store, no parallel side table. -**Authority contract (live, shipped in PR #534):** +**Authority contract (live, implemented in PR #534 — OPEN):** -- **One `WorkflowEffect` per workflow-root Value node.** The root is identified by `NodeId`; the `lane2_workflow` field holds at most one `WorkflowEffect`. No `Dag.workflows: List<...>` sidecar; the carrier is attached to the node it describes. -- **Lowering is the sole producer.** `Dag::try_register_lane2_workflow_effect` is the single write path — it writes the Value node's field and returns `false` if the target node isn't a `ValueNode`. Re-registration semantics are the register API's domain (it currently rejects overwrites of an existing value, per its implementation). +- **One `WorkflowEffect` per workflow-root node.** The root must be a `Value` or `Bind` behavior; other `Behavior` variants cannot host the carrier. The root is identified by `NodeId`; the `lane2_workflow` field holds at most one `WorkflowEffect`. No `Dag.workflows: List<...>` sidecar; the carrier is attached to the node it describes. +- **Lowering is the sole producer.** `Dag::try_register_lane2_workflow_effect` is the single write path — it writes the Value or Bind behavior's field and returns `false` if the target node is neither. Re-registration semantics are the register API's domain (current impl overwrites; the method doesn't return a prior value). - **Readers go through one accessor.** Every consumer reads via `Dag::lane2_workflow_effect_at(root)`; there is no alternative path into the store. -- **Not yet reflected into `.dag` lenses.** Per the shipped code's own comment: *"not part of the reflected `Behavior` surface in `substrate.dag`, so `.dag` lenses cannot read it until a workflow fact is reflected + realized."* This is an explicit Part-3 hole — Lane 2 Stages 2d / 2e / 2f, which are `.dag`-lens consumers, bind only after the reflection lands. Today the analyzer is a Rust consumer. +- **Not yet reflected into `.dag` lenses.** Per the shipped code's own comment: *"not part of the reflected `Behavior` surface in `substrate.dag`, so `.dag` lenses cannot read it until a workflow fact is reflected + realized."* This is an explicit Part-3 hole — Lane 2 Stages 2d / 2e / 2f, which are `.dag`-lens consumers, bind only after the reflection lands. Today the analyzer is a Rust consumer (`workflow_idempotency.rs`). + +**Why both `Value` and `Bind`** (not just `Value`)**.** A workflow root may be either: a top-level `data` declaration whose root is a `Value` behavior (the RHS expression), or a nested workflow computed as part of a `Bind` (let-binding a sub-workflow). Both cases must host a `WorkflowEffect`; the shipped impl attaches the same `lane2_workflow` field to both `Behavior` variants rather than inventing a wrapper. Other `Behavior` kinds (`Transform`, `Branch`, `Loop`) cannot host a workflow root — a `Branch` IS a control-flow node; wrapping one in `WorkflowEffect::BranchEffect` is a type error — so `try_register_lane2_workflow_effect` correctly returns `false` against them. -**Part 3 follow-up — data-declaration authoring surface.** The eventual end-user surface is a `data my_flow: WorkflowEffect = BranchEffect { arms: [ BranchArm { condition: , body: }, ... ] }` declaration whose lowered computation sub-DAG registers a `WorkflowEffect` via `try_register_lane2_workflow_effect`. That path — source-form parsing, expression-to-port wiring, fail-closed diagnostic on non-Bool conditions — is **not shipped in Part 2**. It is Part 3 work tracked against: +**Part 3 follow-up — data-declaration authoring surface.** The eventual end-user surface is a `data my_flow: WorkflowEffect = BranchEffect { arms: [ BranchArm { condition: , body: }, ... ] }` declaration whose lowered computation sub-DAG registers a `WorkflowEffect` via `try_register_lane2_workflow_effect`. That path — source-form parsing, expression-to-port wiring, fail-closed diagnostic on non-Bool conditions — is **not in PR #534**. It is Part 3 work tracked against: -- Value-body carrier reflection (so `.dag` lenses can read `ValueNode.lane2_workflow`; required for Lane 2 Stages 2d / 2e / 2f). -- Surface-to-`WorkflowEffect` lowering (the `Dag::branch_arm_of` call site migrates from today's Rust-side scaffolding to lowering under the data-declaration surface). +- Reflected-substrate carriers so `.dag` lenses can read `lane2_workflow` (required for Lane 2 Stages 2d / 2e / 2f). +- Surface-to-`WorkflowEffect` lowering (the `Dag::branch_arm_of` call site migrates from today's Rust-side scaffolding into a lowering pass under the data-declaration surface). - Diagnostic wiring for non-Bool branch conditions (lowering calls `Dag::branch_arm_of`, emits a `Diagnostic` identifying the condition source span on `None`). **Rejected host alternatives:** see §Rejected alternatives. `Dag.workflows` sidecar table, field-on-`OperationDeclaration`, lens-time reconstruction, new `WorkflowDeclaration` declaration kind — each is enumerated with its rejection reason so newcomers don't re-propose them. ### Source-to-handle contract (Part 3) -The `ValueNode.lane2_workflow` authority above specifies *where* the `WorkflowEffect` lives in the substrate today. This subsection specifies *how* a future user-authored `BranchArm.condition` will reach its `BranchPredicateRef` type once the Part 3 data-declaration surface lands. Today, the only consumer is Rust-side scaffolding that constructs `BranchArm`s via `Dag::branch_arm_of` directly (with a NodeId for the graph root, a PortId for the condition, and a pre-constructed `WorkflowEffect` body). +The `lane2_workflow` authority above specifies *where* the `WorkflowEffect` lives in the substrate today. This subsection specifies *how* a future user-authored `BranchArm.condition` will reach its `BranchPredicateRef` type once the Part 3 data-declaration surface lands. Today, the only caller is Rust-side scaffolding that constructs `BranchArm`s via `Dag::branch_arm_of(&self, port, body)` directly (with a `PortId` for the condition and a pre-constructed `WorkflowEffect` body); port-type validation uses the `Dag`'s own graph state. The locked-ahead contract (to prevent Part 3 from inventing escape hatches): @@ -230,10 +263,10 @@ The locked-ahead contract (to prevent Part 3 from inventing escape hatches): 2. **Lowering path (Part 3, sole authority).** When lowering encounters a `BranchArm.condition` surface-level expression: 1. Lower the expression into a computation sub-DAG via the standard value-expression → sub-DAG path (no DB-18-specific pipeline). - 2. Take the sub-DAG's root output port as a `PortId`, and the enclosing workflow root as a `NodeId`. - 3. Call `Dag::branch_arm_of(root, port, body) -> Option`. + 2. Take the sub-DAG's root output port as a `PortId`. + 3. Call `Dag::branch_arm_of(port, body) -> Option`. 4. On `Some(arm)`: install `arm` into the enclosing `arms` list. - 5. On `None` (port's declared type is not Bool on the root's graph): emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` with the span pointing at the source expression (not the surrounding `BranchArm` or `WorkflowEffect`). Do NOT construct a `BranchArm` on the `None` path. + 5. On `None` (port's declared type is not Bool): emit `Diagnostic::BranchConditionNotBool { port, actual_type, span }` with the span pointing at the source expression (not the surrounding `BranchArm` or `WorkflowEffect`). Do NOT construct a `BranchArm` on the `None` path. This is the SOLE source → `BranchArm` recovery mechanism once Part 3 ships. No alternative. @@ -319,15 +352,15 @@ This is deliberate. PR #529 removed `ComposedEffect { operations, verdict }` bec ### What DB-18 DOES add (summary) -**Part 2 — shipped in PR #534 (eager-fox-851):** -- Rust enum `WorkflowEffect` (four-variant coproduct) + struct `BranchArm` + struct `BranchPredicateRef` (private inner field) in `src/v3/compiler/src/dag.rs`. -- Field `ValueNode.lane2_workflow: Option>` — the authority site. -- `Dag::branch_arm_of(root, port, body) -> Option` — sole constructor; validates port is Bool-typed on the root's graph. -- `Dag::try_register_lane2_workflow_effect` + `Dag::lane2_workflow_effect_at` — writer / reader accessors. -- `workflow_idempotency::analyze_workflow` — analyzer: `LinearEffect` → `CompositionVerdict`; other variants → `WorkflowIdempotencyReport::Unsupported`. +**Part 2 — implemented in PR #534 (eager-fox-851, OPEN):** +- Rust enum `WorkflowEffect` (four-variant, **🟡 scaffold** — graduates to 🟢 when all four variants have consumers) + struct `BranchArm` + struct `BranchPredicateRef` (crate-private inner field) in `src/v3/compiler/src/dag.rs`. +- Fields `Value.lane2_workflow` and `Bind.lane2_workflow: Option>` — authority site on both behaviors; other `Behavior` kinds cannot host. +- `Dag::branch_arm_of(&self, port, body) -> Option` — sole `BranchArm` producer; validates port resolves to `Bool` on the graph. +- `Dag::try_register_lane2_workflow_effect` + `Dag::lane2_workflow_effect_at` — writer / reader accessors (both pattern-match `Value` and `Bind`). +- `workflow_idempotency::analyze_workflow` — analyzer: `LinearEffect` → `CompositionVerdict`; other variants → `WorkflowIdempotencyReport::Unsupported { detail }`. -**Part 3 — follow-up (not shipped):** -- Reflection of `ValueNode.lane2_workflow` into the `.dag` `substrate.dag` surface (unblocks Lane 2 Stages 2d / 2e / 2f as `.dag` consumers). +**Part 3 — follow-up (not in #534):** +- Reflection of `lane2_workflow` into the `.dag` `substrate.dag` surface (unblocks Lane 2 Stages 2d / 2e / 2f as `.dag` consumers; graduates the 🟡 scaffold marker). - Data-declaration authoring surface: lowering a `data my_flow: WorkflowEffect = ...` literal into computation-substrate sub-DAGs that call `Dag::branch_arm_of` and `Dag::try_register_lane2_workflow_effect`. - Fail-closed `Diagnostic::BranchConditionNotBool` wired to the data-declaration lowering path with source-span precision. @@ -345,15 +378,17 @@ STS.exchange(token=...) // ReadEffect (idempotent on state) IAM.grant(role=...) // UpsertEffect ``` -Encoded as: +Encoded as (ops is `NonEmptyList`): -``` -WorkflowEffect::LinearEffect { - ops: [ - OperationEffect { operation_name: "Secret.upsert", shape: IsIdempotent(UpsertEffect{..}) } - OperationEffect { operation_name: "STS.exchange", shape: IsIdempotent(ReadEffect) } - OperationEffect { operation_name: "IAM.grant", shape: IsIdempotent(UpsertEffect{..}) } - ] +```rust +WorkflowEffect::Linear { + ops: NonEmptyList { + first: OperationEffect { name: "Secret.upsert", shape: EffectShape::IsIdempotent(UpsertEffect { .. }) }, + rest: vec![ + OperationEffect { name: "STS.exchange", shape: EffectShape::IsIdempotent(ReadEffect) }, + OperationEffect { name: "IAM.grant", shape: EffectShape::IsIdempotent(UpsertEffect { .. }) }, + ], + }, } ``` @@ -373,19 +408,23 @@ Encoded as `LinearEffect { ops: [upsert, exchange, grant, append] }`. Stage 2b c **Workflow C — retry with nested branch (Stage 2b diagnostic).** ```rust -// workflow_root is the NodeId of the root ValueNode on which the -// WorkflowEffect will be registered via Dag::try_register_lane2_workflow_effect. -// port_auth_ok and port_auth_failed are PortId values produced on that graph. -// Dag::branch_arm_of(root, port, body) validates each port resolves to Bool on -// the root's graph and constructs the BranchArm in one step; a non-Bool port +// workflow_root is the NodeId of the root Value or Bind behavior on which +// the WorkflowEffect is registered via Dag::try_register_lane2_workflow_effect. +// port_auth_ok and port_auth_failed are PortId values on the graph. +// Dag::branch_arm_of(&self, port, body) validates the port resolves to Bool +// on the graph and constructs the BranchArm in one step; a non-Bool port // returns None and the caller is required to emit a Diagnostic identifying // the condition source (no silent absorption — C-8). let arm_ok = dag - .branch_arm_of(workflow_root, port_auth_ok, LinearEffect { ops: vec![secret_get] }.into()) - .expect("port_auth_ok resolves to Bool on the workflow graph"); + .branch_arm_of(port_auth_ok, WorkflowEffect::Linear { + ops: NonEmptyList::singleton(secret_get), + }) + .expect("port_auth_ok resolves to Bool on the graph"); let arm_failed = dag - .branch_arm_of(workflow_root, port_auth_failed, LinearEffect { ops: vec![sts_exchange, secret_get] }.into()) - .expect("port_auth_failed resolves to Bool on the workflow graph"); + .branch_arm_of(port_auth_failed, WorkflowEffect::Linear { + ops: NonEmptyList::from_iter_nonempty([sts_exchange, secret_get]), + }) + .expect("port_auth_failed resolves to Bool on the graph"); let workflow = LoopEffect { body: BranchEffect { arms: non_singleton_list![arm_ok, arm_failed] }.into(), From 70593b2a0f01414209b0fce4e03eef71a168daea Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:33:37 -0400 Subject: [PATCH 28/29] WIP: B' --- docs/design-db18-workflow-effect-carrier.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/design-db18-workflow-effect-carrier.md b/docs/design-db18-workflow-effect-carrier.md index 65d4e9014ec..d8423509188 100644 --- a/docs/design-db18-workflow-effect-carrier.md +++ b/docs/design-db18-workflow-effect-carrier.md @@ -124,9 +124,9 @@ Per `feedback_coproduct_dissolution`, every new coproduct must pass the four-pat | Variant A | Variant B | Payload A | Payload B | Distinct? | |---|---|---|---|---| -| `LinearEffect` | `BranchEffect` | `List` | `NonSingletonList` | ✓ different element types + different cardinality contract | -| `LinearEffect` | `LoopEffect` | `List` | `WorkflowEffect` | ✓ list vs single | -| `LinearEffect` | `ParallelEffect` | `List` | `NonSingletonList` | ✓ different element types + different cardinality contract | +| `LinearEffect` | `BranchEffect` | `NonEmptyList` | `NonSingletonList` | ✓ different element types + different cardinality contract | +| `LinearEffect` | `LoopEffect` | `NonEmptyList` | `WorkflowEffect` | ✓ list vs single | +| `LinearEffect` | `ParallelEffect` | `NonEmptyList` | `NonSingletonList` | ✓ different element types + different cardinality contract | | `BranchEffect` | `LoopEffect` | `NonSingletonList` | `WorkflowEffect` | ✓ list vs single | | `BranchEffect` | `ParallelEffect` | `NonSingletonList` | `NonSingletonList` | ✓ `BranchArm` ≠ `WorkflowEffect` (BranchArm carries `condition: BranchPredicateRef` that ParallelEffect branches do not; the typed-witness field is unrepresentable in ParallelEffect's element type) | | `LoopEffect` | `ParallelEffect` | `WorkflowEffect` | `NonSingletonList` | ✓ single vs list | @@ -135,7 +135,7 @@ The critical pair is BranchEffect vs ParallelEffect — both use `NonSingletonLi **Pattern 3 — Algebraic-form (traces to intro/elim of algebraic structures).** The four variants trace to four distinct categorical operations on effect algebras: -- `LinearEffect` = **monoidal composition (∘)**. `compose_effects` already witnesses this: a linear sequence of effects composes as `∘` with `ReadEffect` (identity on state) as the unit on the effect-shape side AND with the empty list `[]` as the unit on the carrier side. The `CompositionVerdict` is the algebra's output for this case (`compose_effects([])` = `IdempotentComposition`, the identity verdict). The carrier `List` is the free monoid on `OperationEffect`, including the empty word as the monoidal identity. +- `LinearEffect` = **monoidal composition (∘)**. `compose_effects` witnesses this: a linear sequence of effects composes as `∘` with `ReadEffect` (identity on state) as the unit on the effect-shape side. The `CompositionVerdict` is the algebra's output for this case. The carrier `NonEmptyList` is the free semigroup on `OperationEffect` (the identity element — the empty word — is not representable in the input carrier; when a `compose_effects` caller passes the algebra `[]` at the algebra boundary, the algebra returns `IdempotentComposition`, but the workflow input carrier itself enforces ≥1 so "no workflow here" is distinguished from "a linear workflow with zero ops"). - `BranchEffect` = **coproduct (∨) / lattice meet over arms**. Exactly one arm executes at runtime; the verdict must hold for whichever arm is taken. At compile time this is a `∨`-over-arms: the workflow is idempotent iff every arm's workflow is idempotent. Structurally distinct from `∘` — composition order does not matter for arms that are alternatives, and the per-arm verdicts combine via lattice meet, not via monoidal multiplication. - `LoopEffect` = **fixpoint (μ) / iteration**. The body is re-applied 0..N times (bound structure tracked separately, potentially in Part 2). Idempotency under iteration demands the body itself be idempotent (`f ∘ f = f`) — a strictly stronger condition than linear composition, because monoidal composition of two different non-idempotent effects can still converge, but iteration of one non-idempotent effect cannot. - `ParallelEffect` = **concurrent product (⊗) / commutative composition**. Branches execute concurrently and require algebraic commutativity on the target state to compose safely. The verdict's validity depends on commutativity evidence (open question §1 below — Part 2 or Stage 2e may extend the carrier with a `commutativity` witness). Distinct from both `∘` (order-preserving) and `∨` (alternatives): `⊗` is unordered AND concurrent. @@ -152,7 +152,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q1 — Cardinality invariants.** Does any variant admit `[]` where invariant says ≥1, or singletons where ≥2? -- `LinearEffect.ops: List` — empty is explicitly allowed as the **monoidal identity** (the unit element of the free monoid on `OperationEffect`). `compose_effects([])` already returns `IdempotentComposition`; the type accepts empty for carrier/algebra symmetry. This is also the honest representation of a no-op workflow (e.g., a branch arm whose body does nothing). No ≥1 invariant applies here; the invariant is "a list of ops (zero or more)," matching the algebra. +- `LinearEffect.ops: NonEmptyList` — a linear workflow with zero operations is structurally unrepresentable; the carrier enforces ≥1 by type. (The monoidal-identity case is handled at the algebra level — `compose_effects([])` on the `List` the caller passes to `compose_effects` returns `IdempotentComposition` — but the workflow-*input* side does not need the zero-element carrier, and admitting it would create a representation-duality ambiguity with "no workflow here." The shipped impl chose the tighter carrier.) - `BranchEffect.arms: NonSingletonList` — a branch with 0 or 1 arms is meaningless (0: no workflow; 1: just the arm itself, use the arm's `body` directly). Type rejects both. - `LoopEffect.body: WorkflowEffect` — singleton (one body), never empty. Type is non-optional. - `ParallelEffect.branches: NonSingletonList` — 0 branches is empty parallelism; 1 branch is just the branch itself, no concurrency. Type rejects both. ✓ cardinality invariants at the type level (including the correctly-permissive empty `LinearEffect` case); no "lowering guarantees" prose required. @@ -172,7 +172,7 @@ Per `feedback_substrate_principle_audit`, all six questions walk before greenlig **Q6 — Representation duality.** Can the same fact be expressed in two structurally different shapes that comparison treats differently? -- A workflow that is structurally a sequence with a nested branch is expressed *uniquely* as the outermost variant's shape. "Linear with branch in the middle" cannot be represented as `LinearEffect` (because `LinearEffect.ops` is `List`, not `List`); it must be lifted to `BranchEffect { arms: [LinearEffect{A∪B∪D}, LinearEffect{A∪C∪D}] }` — i.e., a branch between two linear paths. This is a structurally-unique canonical form, not a choice between two equivalent representations. A no-op workflow has exactly one canonical form — `LinearEffect { ops: [] }` — and cannot be spelled another way under the 4-variant coproduct. ✓ no representation duality. +- A workflow that is structurally a sequence with a nested branch is expressed *uniquely* as the outermost variant's shape. "Linear with branch in the middle" cannot be represented as `LinearEffect` (because `LinearEffect.ops` is `NonEmptyList`, not a list of `WorkflowEffect`); it must be lifted to `BranchEffect { arms: [LinearEffect{A∪B∪D}, LinearEffect{A∪C∪D}] }` — i.e., a branch between two linear paths. This is a structurally-unique canonical form, not a choice between two equivalent representations. A node without a workflow has exactly one canonical state — `lane2_workflow: None` on the Value/Bind behavior — and cannot be spelled as a "zero-ops LinearEffect" because the carrier's `NonEmptyList` rejects that. ✓ no representation duality. All six audit questions stamp cleanly. The full six-question audit is recorded in this section for reviewer-bot verification rather than re-derivation at PR-review time. @@ -455,7 +455,7 @@ Design-contract items (locked in this doc, independent of shipping-phase): 2. `Dag::branch_arm_of(root, port, body) -> Option` is the sole `BranchArm` constructor; validates `port` resolves to `Bool` on the graph rooted at `root`. 3. `Dag::try_register_lane2_workflow_effect(root, workflow) -> bool` and `Dag::lane2_workflow_effect_at(root) -> Option<&WorkflowEffect>` are the write / read accessors for the authority site. 4. `src/v3/compiler/src/workflow_idempotency.rs` declares `analyze_workflow(dag, root) -> WorkflowIdempotencyReport` dispatching per variant: `LinearEffect` delegates to `compose_effects` and returns a verdict; the other three variants return `WorkflowIdempotencyReport::Unsupported { detail: IdempotencyUnsupportedDetail }` identifying the variant and its downstream stage. -5. Structural tests cover: GCP-style linear green path; terminal `AppendEffect` red path; fail-closed on non-Bool branch conditions (`Dag::branch_arm_of` returns `None`); empty-`LinearEffect` monoidal-identity case; diagnostic paths for `BranchEffect` / `LoopEffect` / `ParallelEffect`. +5. Structural tests cover: GCP-style linear green path; terminal `AppendEffect` red path; fail-closed on non-Bool branch conditions (`Dag::branch_arm_of` returns `None`); diagnostic paths for `BranchEffect` / `LoopEffect` / `ParallelEffect`. **Part 3 — follow-up (NOT shipped by PR #534):** @@ -489,7 +489,7 @@ The director chat owns the call on each of these. Silent in-flight patches destr 3. **Does `LoopEffect.body` need a bound carrier in Part 1?** Substrate has `LoopBound = Cardinality { count: PortId } | Descent { cluster: ClusterId }`. Stage 2d (symbolic cost) will almost certainly need the bound to compute recursion depth. Part 1 does NOT include a bound field because Stage 2b emits a diagnostic on `LoopEffect` without reading bound info. Part 2 / Stage 2d can add `bound: LoopBound` as an additive extension when its consumer binds — consistent with Acceptance item 1's additive-extension clause. If director chat prefers a single shape-lock including the bound, a future DB revision adds `LoopEffect { body: WorkflowEffect, bound: LoopBound }`; decision is a scope judgment, not a correctness one. -4. **Does `LinearEffect.ops` need to be `List` instead of `List` to allow mixed nesting?** The worked example C demonstrates the answer: a linear sequence with a branch in the middle lifts to a `BranchEffect` of two linear paths (distributive over composition). No `LinearEffect` with a `WorkflowEffect` in the middle is needed; the structural canonical form is always a variant at the outermost shape. Locked per Q6 (no representation duality) — not open. +4. **Does `LinearEffect.ops` need to be `NonEmptyList` instead of `NonEmptyList` to allow mixed nesting?** The worked example C demonstrates the answer: a linear sequence with a branch in the middle lifts to a `BranchEffect` of two linear paths (distributive over composition). No `LinearEffect` with a `WorkflowEffect` in the middle is needed; the structural canonical form is always a variant at the outermost shape. Locked per Q6 (no representation duality) — not open. --- From 20c5814ca8b21a59f4271a39408ca8fbd069f163 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 18 Apr 2026 17:34:16 -0400 Subject: [PATCH 29/29] =?UTF-8?q?docs:=20DB-18=20=E2=80=94=20verify-agains?= =?UTF-8?q?t-=CE=B2=20fixes=20(5=20points)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline review at 2026-04-18T21:27:28Z flagged specific verifiable discrepancies between my remediation and β's actual PR #534 impl. Verified each claim against β's current head (a4dfa2e87) and fixed all five: 1. **PR #534 is OPEN, not merged.** Changed "shipped in PR #534" to "implemented in PR #534 (OPEN)" throughout. The doc now tracks #534's head SHA; "OPEN" marker becomes the merge SHA when #534 lands. 2. **WorkflowEffect is 🟡 SCAFFOLD, not 🟢 terminal.** β's impl explicitly marks it as scaffold ("Four-variant workflow sum aligned with effects.dag; graduates to 🟢 when all four variants have consumers"). The doc now reflects this with the graduation trigger spelled out (all four variants have consumers + reflected into substrate.dag). 3. **LinearEffect.ops is NonEmptyList, not List.** β did NOT adopt R2's monoidal-identity relaxation; shipped NonEmptyList. The doc now reflects live: - Substrate-changes block shows NonEmptyList - Q1 audit explains why β chose the tighter carrier (no representation duality between "zero-ops LinearEffect" and "no workflow here") - Pattern 3 algebraic-form explains the free-semigroup vs free-monoid distinction (workflow input carrier is the semigroup; monoidal identity lives at the algebra boundary) - Q6 no-representation-duality argument reworked: no-workflow is lane2_workflow: None, NOT a zero-ops LinearEffect - Worked Examples A and C use NonEmptyList constructor form - Open Q §4 updated to NonEmptyList vs NonEmptyList - Part 2 test-coverage list drops the "empty-LinearEffect monoidal-identity case" (not representable) 4. **lane2_workflow lives on BOTH Value AND Bind behaviors**, not just Value. β's try_register + lane2_workflow_effect_at both pattern-match the two variants. Authority-site section rewritten to show fields on both behaviors and the accessor impls. Added a paragraph explaining why both (Value for top-level data-decl roots; Bind for let-bound sub-workflows). 5. **Dag::branch_arm_of takes (&self, port, body), no root arg.** Port-type resolution uses the Dag's own graph state. Substrate- changes block, source-to-handle contract (Part 3), Worked Example C, Acceptance items, and STOP-AND-ESCALATE rules all updated to the 2-arg signature. All five are factual fixes against live code, not stylistic changes. The Q4 dissolution receipt and overall design contract are preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/lane2-compile-time-proofs.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/lane2-compile-time-proofs.md b/docs/lane2-compile-time-proofs.md index 81c1b6025cd..a5cf2f11850 100644 --- a/docs/lane2-compile-time-proofs.md +++ b/docs/lane2-compile-time-proofs.md @@ -102,26 +102,26 @@ Lens reads each operation's declared `idempotent` modifier AND derives from path **Escalation:** if workflow structure isn't representable cleanly — e.g., control flow in a pipeline doesn't map to a linear `List` — surface. Don't stretch `compose_effects` to handle branches silently; the algebra needs to reflect branch-wise composition, which is a legitimate design extension. -**Input structure (DB-18) — `WorkflowEffect` substrate carrier.** DB-18 lifts workflow control-flow shape from "something the lens reconstructs from DAG shape at walk time" (a heuristic that `feedback_lenses_not_passes` rejects) to a first-class substrate carrier. Shipped in PR #534 (eager-fox-851) as Rust mirrors in `src/v3/compiler/src/dag.rs`: +**Input structure (DB-18) — `WorkflowEffect` substrate carrier.** DB-18 lifts workflow control-flow shape from "something the lens reconstructs from DAG shape at walk time" (a heuristic that `feedback_lenses_not_passes` rejects) to a first-class substrate carrier. Implemented in PR #534 (eager-fox-851, **OPEN**) as Rust mirrors in `src/v3/compiler/src/dag.rs`; marker is **🟡 SCAFFOLD** — graduates to 🟢 when all four variants have consumers and the carrier is reflected into `substrate.dag`. ```rust -pub enum WorkflowEffect { - Linear { ops: Vec }, // empty = monoidal identity - Branch { arms: NonSingletonList }, +pub enum WorkflowEffect { // 🟡 SCAFFOLD + Linear { ops: NonEmptyList }, // ≥1 by type; no empty + Branch { arms: NonSingletonList }, // ≥2 arms by type Loop { body: Box }, Parallel { branches: NonSingletonList> }, } pub struct BranchArm { condition: BranchPredicateRef, body: Box } -pub struct BranchPredicateRef { port: PortId } // opaque; no public constructor +pub struct BranchPredicateRef { port: PortId } // crate-private field; no public constructor ``` -Authority site: `ValueNode.lane2_workflow: Option>` on the computation-substrate Value node at the workflow root. Writes via `Dag::try_register_lane2_workflow_effect(root, workflow)`; reads via `Dag::lane2_workflow_effect_at(root)`. One workflow per root; no sidecar table; no parallel hosting. Stage 2b's analyzer (`src/v3/compiler/src/workflow_idempotency.rs::analyze_workflow(dag, root)`) walks the stored workflow and dispatches per variant: `LinearEffect` delegates to `compose_effects` and returns `WorkflowIdempotencyReport::Linear { verdict: CompositionVerdict }` (empty `ops` yields `IdempotentComposition` — the monoidal identity); the other three variants return `WorkflowIdempotencyReport::Unsupported { detail: IdempotencyUnsupportedDetail }` naming the downstream stage (`BranchEffect` / `LoopEffect` → Lane 2 Stage 2d; `ParallelEffect` → Lane 2 Stage 2e with commutativity witness). No silent skip; `IdempotencyUnsupportedDetail` carries the variant identity and its downstream consumer so the user (and CI) can distinguish "broken" from "out of scope." +Authority site: `lane2_workflow: Option>` on BOTH `Value` and `Bind` computation-substrate behaviors (other `Behavior` kinds cannot host). Writes via `Dag::try_register_lane2_workflow_effect(root, workflow)`; reads via `Dag::lane2_workflow_effect_at(root)` — both pattern-match `Value` and `Bind`. One workflow per root `NodeId`; no sidecar table; no parallel hosting. Stage 2b's analyzer (`src/v3/compiler/src/workflow_idempotency.rs::analyze_workflow(dag, root)`) walks the stored workflow and dispatches per variant: `Linear` delegates to `compose_effects` and returns `WorkflowIdempotencyReport::Linear { verdict: CompositionVerdict }`; the other three variants return `WorkflowIdempotencyReport::Unsupported { detail: IdempotencyUnsupportedDetail }` naming the downstream stage (`Branch` / `Loop` → Lane 2 Stage 2d; `Parallel` → Lane 2 Stage 2e with commutativity witness). No silent skip; `IdempotencyUnsupportedDetail` carries the variant identity and its downstream consumer so the user (and CI) can distinguish "broken" from "out of scope." -`BranchPredicateRef`'s sole constructor is `Dag::branch_arm_of(root, port, body) -> Option`, which validates `port` resolves to `Bool` on the root's graph; `None` is the typed failure that callers must surface as a `Diagnostic`. `WorkflowEffect` and `CompositionVerdict` (PR #529) coexist on orthogonal axes — input structure vs output verdict; no enclosing record pairs them (preserving the PR #529 R3 lesson). +`BranchArm`'s sole producer is `Dag::branch_arm_of(&self, port, body) -> Option`, which validates `port` resolves to `Bool` on the graph; `None` is the typed failure that callers must surface as a `Diagnostic`. `WorkflowEffect` and `CompositionVerdict` (PR #529) coexist on orthogonal axes — input structure vs output verdict; no enclosing record pairs them (preserving the PR #529 R3 lesson). -Design: [design-db18-workflow-effect-carrier.md](./design-db18-workflow-effect-carrier.md) — locks the 4-variant shape, typed `BranchPredicateRef` witness, `ValueNode.lane2_workflow` authority site, and the Q1–Q6 substrate-principle audit. **Part 2 shipped in PR #534.** Part 3 follow-up (not yet shipped) covers (a) reflection of `ValueNode.lane2_workflow` into the `.dag` substrate surface so Lane 2 Stages 2d / 2e / 2f `.dag` lenses can read it, and (b) the data-declaration authoring surface `data my_flow: WorkflowEffect = ...` with lowering to `Dag::branch_arm_of` + `Dag::try_register_lane2_workflow_effect` and fail-closed `Diagnostic::BranchConditionNotBool` on non-Bool conditions. +Design: [design-db18-workflow-effect-carrier.md](./design-db18-workflow-effect-carrier.md) — locks the 4-variant shape, typed `BranchPredicateRef` witness, `lane2_workflow` authority site on `Value`/`Bind`, and the Q1–Q6 substrate-principle audit. **Part 2 implemented in PR #534 (OPEN).** Part 3 follow-up (not in #534) covers (a) reflection of `lane2_workflow` into the `.dag` substrate surface so Lane 2 Stages 2d / 2e / 2f `.dag` lenses can read it, and (b) the data-declaration authoring surface `data my_flow: WorkflowEffect = ...` with lowering to `Dag::branch_arm_of` + `Dag::try_register_lane2_workflow_effect` and fail-closed `Diagnostic::BranchConditionNotBool` on non-Bool conditions. **Pre-start gate:** cleared. All Stage 2a follow-ups have landed — `DerivedOpEffect` collapsed into `OperationEffect` (PR #521); `EffectShape` partitioned into `IsIdempotent(IdempotentShape) | IsBreaking(BreakingShape)`; `BrokenBy`'s payload narrowed to `BreakingOperation { shape: BreakingShape }`; `ComposedEffect` removed. `compose_effects(effects: List) -> CompositionVerdict` is the algebra's output. Stage 2b consumes `CompositionVerdict` directly; callers pair it with their own input `List` for diagnostic rendering. The Stage 2b `WorkflowIdempotencyReport` shape above is one layer higher (a lens report with diagnostic), not a duplication of the algebra verdict — when Stage 2b implements it, the report must match on `CompositionVerdict` and project through `BrokenBy.first_breaker.shape: BreakingShape`. The `breaking_op: String?` field in the draft `WorkflowIdempotencyReport` shape above is a design placeholder from before the Stage 2a partition landed; Stage 2b's implementer should replace it with a structural carrier derived from `CompositionVerdict` rather than copy the flat shape. The implementer must not reintroduce a `ComposedEffect`-style record that pairs `CompositionVerdict` with a sibling list field, since that reintroduces the correlated-fields incoherence R3 just removed.