From 1f4048308430fbad6e948bf9b8343caac9d474e4 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 26 May 2026 01:19:56 +0200 Subject: [PATCH 1/5] FE-747: Add petri-declarative-routing frontier to plan. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New TRACK F frontier between petri-epic-verification-merge (done) and petri-graph-compilation (horizon, blocked on FE-700). Moves conditional output routing from wireHandlers fire closures into typed Guard predicates declared on HandlerDescriptor so a topology-only consumer can enumerate reachable output places per transition without invoking actions, reports, or the test runner — structural prerequisite for any static analysis (simulation, reachability, deadlock detection) and FE-700-independent. Retires the "Declarative output arcs" sub-bullet under petri-graph-compilation since it's now its own frontier; keeps "Token state enrichment" there. Co-Authored-By: Claude --- memory/PLAN.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index 3fa87723..9345ea6a 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -17,7 +17,7 @@ The interaction model is mature: four-phase interview, interviewer-autonomous qu The next product arc is the **Conversational Workspace Runtime** umbrella (`docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md`) plus a stronger semantic/generative substrate. The umbrella synthesizes MULTI_CHAT, SIDE_CHAT, PATCH_LEDGER, and CONTINUOUS_WORKSPACE_HYBRID into five sub-tracks: workspace shell (Track 1, shipped as `continuous-workspace` / FE-709), inline secondary-chat runtime over the existing chat/turn substrate (`chat-runtime-secondary-chats`), reconciliation runtime absorption (`reconciliation-runtime`), changeset ledger (`changeset-ledger`), and transcript-first chat context provision (`chat-context-provision`). The shell is now the stable host; schema-level `thread` is deferred until chat/turn proves insufficient. Secondary chats are the near-term runtime primitive for side, reconciliation, qa, and strategy conversations. The chat runtime is the critical unblocker for reconciliation absorption; chat context provision can proceed against chat/turn with explicit transcript snapshots and graph-item handles. The changeset ledger runs in parallel. The umbrella supersedes the independent side-chat V4a persistence horizon — persistent side-chat history becomes inline secondary chats in the workspace. The FE-705 branch contributes an integration substrate — a local agent capability CLI and external LLM-as-user probe harness — that should be reconciled into main before graph-review and scenario-options work depends on generated completed-spec fixtures. After that, the highest-coordination work is intent-graph semantics and the semantic changeset ledger; FE-701 should follow soon after the FE-705 reconciliation because the current schema already carries transitional multi-chat / reconciliation placeholders that only become coherent once `changeset` / `change` owns semantic mutation history. Lower-coordination provider, gitignore, and web-research work can proceed in parallel. -The **orchestrator / Petri-net execution substrate** is committed (2026-05-21) to Petri as the forward execution model, justified by parallelism, simulation, and resume value claims. Phases 0–2 are done: the dual-engine PoC (Phase 0, FE-730) validated the substrate and extracted the compiler/interpreter; Phase 1 (FE-738) added two-lane mechanical+semantic subnets, the compiler topology/wiring split, and §7 event vocabulary; Phase 2 (FE-743) added parallel firing policy with greedy token claiming, shared resource pool tokens bounding global concurrency, and worktree-per-slice isolation — the decision gate passed (parallel measurably beats serial on wall clock). Phases 3–4 (graph compilation, simulation oracle) are on the horizon pending `intent-graph-semantics` (FE-700) and relation-policy readiness. The north-star design is `docs/next/architecture/plan-graph-petri-orchestration.md`. +The **orchestrator / Petri-net execution substrate** is committed (2026-05-21) to Petri as the forward execution model, justified by parallelism, simulation, and resume value claims. Phases 0–2 are done: the dual-engine PoC (Phase 0, FE-730) validated the substrate and extracted the compiler/interpreter; Phase 1 (FE-738) added two-lane mechanical+semantic subnets, the compiler topology/wiring split, and §7 event vocabulary; Phase 2 (FE-743) added parallel firing policy with greedy token claiming, shared resource pool tokens bounding global concurrency, and worktree-per-slice isolation — the decision gate passed (parallel measurably beats serial on wall clock). Phase 3 (graph compilation) remains blocked on `intent-graph-semantics` (FE-700) for relation-policy gates; the Phase-3-prep step `petri-declarative-routing` is FE-700-independent and slots in next (moves conditional routing from fire closures into typed Guard predicates on `HandlerDescriptor`, the structural prerequisite for any static analysis including Phase 4 simulation). The north-star design is `docs/next/architecture/plan-graph-petri-orchestration.md`. The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agent-mutation design notes are reconciled into one direction. `docs/design/MULTI_CHAT.md` is the substrate document. `docs/design/SIDE_CHAT.md` describes side-chat V1 / V2 / V3.0 / V3.1 / V4 phasing on top of that substrate. `docs/design/PATCH_LEDGER.md` remains historical deeper design pressure for semantic mutation history, but canonical future-facing vocabulary is `changeset` / `change`. The product-layer ontology trajectory is split out as `docs/design/INTENT_GRAPH_SEMANTICS.md` and `docs/design/BEHAVIORAL_KERNELS.md`; broader synthesis lives in `docs/archive/design/INTENT_SPEC_EVOLUTION.md`. FE-705's branch-local strategy/proposal notes add scenario options, graph-review oracle, chat-local strategies, and concern/dependency mapping; those notes should become a canonical design doc when the branch is integrated. Coordination uses a substrate-strangler posture: keep existing frontend REST/SSE contracts stable while route adapters and capability adapters converge on shared server-owned handlers, then cut over UI flows only after parity and changeset-backed authority exist. The dev-layer self-tooling trajectory lives in `docs/design/ln-skills/EVOLUTION.md`. @@ -27,6 +27,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen 1. `agent-fixture-substrate` — branch-complete off main, reconciling — FE-705 integration substrate for JSONL agent capability CLI and LLM-as-user probes. 2. `chat-runtime-secondary-chats` — FE-716; V1 done — PR #141 merged to main. +3. `petri-declarative-routing` — new — Phase-3-prep slice that decouples topology from runtime routing closures; stacks off FE-745. ### Recently Completed @@ -103,7 +104,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Objective:** Compile Petri nets from workspace plan-graph nodes and relation-policy edges rather than from YAML plan fixtures. Relation kinds (`plan.depends_on`, `plan.verified_by_oracle`, `plan.introduces_design`, etc.) compile into topology-level requirements (prerequisite tokens, guard predicates, semantic-lane join conditions). Extends the FE-700 relation-policy registry. - **Why now / unlocks:** Without graph compilation, the Petri engine only runs hand-authored YAML plans. Graph compilation makes the engine a planning oracle (simulate before executing) and connects execution to the semantic workspace. - **Open design constraints (from PR #143 / FE-743 review):** - - **Declarative output arcs:** Current topology declares only input places; output routing lives in fire closures (conditional on report payloads). FE-738's `HandlerDescriptor` declares candidate outputs (`onTrue`/`onFalse`/`onPass`/`onFail`) but selection is runtime. This limits formal analyzability (reachability, deadlock detection, simulation) to input-side structure. Phase 3 should move conditional routing into the topology — explicit guard predicates + declared output arcs per branch — so the compiled net is formally analyzable end-to-end. + - **Declarative output arcs:** Extracted to its own frontier `petri-declarative-routing` (lands ahead of Phase 3; independent of FE-700). - **Token state enrichment:** Open question whether more metadata should move from reports into tokens (richer typed token payloads per spec §3). FE-738 added `reworkCount`, FE-743 added pool tokens with `agentPoolSize`, but the boundary between control state (tokens) and substantive handoff state (reports) is a design choice this frontier needs to resolve as the token taxonomy gets richer. - **Acceptance:** TBD — depends on FE-700 relation-policy shape. - **Verification:** Compiled-net topology tests against plan-graph fixtures; reachability assertions for relation-policy-derived gates; comparison of compiled vs hand-authored net shapes. @@ -124,6 +125,19 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Traceability:** Requirements 46–50; spec §3 (token taxonomy — resource tokens), §4 (canonical slice-net terminal join). - **Design docs:** `docs/next/architecture/plan-graph-petri-orchestration.md`; `docs/design/orchestrator.md`; umbrella H-6476. +### petri-declarative-routing + +- **Name:** Petri declarative output arcs — topology-level routing for branching transitions +- **Linear:** unassigned in this plan snapshot +- **Kind:** structural +- **Status:** not-started +- **Objective:** Move conditional output routing from `wireHandlers` fire closures into typed `Guard` predicates declared on `HandlerDescriptor`, so a topology-only consumer can enumerate every reachable output place per transition without invoking actions, reports, or the test runner. +- **Why now / unlocks:** First Phase-3 prep step that does not depend on FE-700. Today's `HandlerDescriptor` already names candidate output places (`onTrue`/`onFalse`, `onPass`/`onFail`, `onSatisfied`/`onRejected`) but the guard predicates that select among them live in runtime closures in `net-compiler.ts`. Without declarative guards, formal analyses (reachability, deadlock detection, simulation) can only see input-side structure — which makes `petri-simulation-oracle` (Phase 4) impossible regardless of whether Phase 3 graph compilation lands. Token transforms (`reportId` attach, budget decrement, retry/rework propagation) and budget-exhaustion halts stay in closures and become separate follow-on slices. +- **Acceptance:** (1) `HandlerDescriptor` branches that route conditionally carry typed `Guard` data; initial vocabulary covers `always` and `reportFieldTruthy`; extension shape is documented. (2) `wireHandlers` consumes guards via a pure `evalGuard(guard, report)` helper; conditional routing logic moves out of inline closure code. (3) A pure `enumerateCandidateOutputs(transition: TransitionSkeleton): Set` function returns the topology-derived output place set per transition without instantiating actions/reports/testRunner. (4) Engine contract suite passes unchanged across both engines. +- **Verification:** Existing engine-contract tests (12+ scenarios, both engines) prove runtime equivalence; new adapter tests pin `enumerateCandidateOutputs` and `evalGuard` against the `simplePlan` and `depPlan` fixtures; new tests run with topology-only inputs (no actions, no reports, no test runner). +- **Traceability:** Requirements 46, 47, 48; D156-K (Phase-3-prep refinement of FE-738 HandlerDescriptor design); candidate new invariant on build: "Topology output-place candidates are fully declared in `HandlerDescriptor`; `wireHandlers` introduces no new output places at fire time." +- **Design docs:** `docs/next/architecture/plan-graph-petri-orchestration.md` §6 (transition contracts), §10 (prototypes); umbrella H-6476. + ### continuous-workspace - **Name:** Continuous workspace / phase-addressable interview surface (Conversational Workspace Runtime — Track 1) @@ -506,9 +520,9 @@ orchestrator-poc (Phase 0: compiler extraction — done) └──→ petri-semantic-lanes (Phase 1: two-lane subnet + §7 events — done) └──→ petri-parallel-execution (Phase 2: concurrent firing + resource pools — done) ├──→ petri-epic-verification-merge (hardening: merge slice worktrees for verify-epic — done) - └──→ petri-graph-compilation (Phase 3: compile from plan-graph + relation policy) - ├──→ depends on intent-graph-semantics (FE-700) for relation-policy gates - └──→ petri-simulation-oracle (Phase 4: reachability, deadlock, resume) + └──→ petri-declarative-routing (Phase-3-prep: topology-level Guard predicates; FE-700-independent) + ├──→ petri-graph-compilation (Phase 3: compile from plan-graph + relation policy; needs FE-700) + └──→ petri-simulation-oracle (Phase 4: reachability, deadlock, resume; declarative routing is the structural prerequisite for static analysis; Phase 3 still needed for graph-derived gates) LOWER-PRIORITY / DEFERRED side-chat-v4b-item-versioning (depends on changeset-ledger) From 07661a72ac65686c6619e3a5e5379fdd529eabb8 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 26 May 2026 01:29:39 +0200 Subject: [PATCH 2/5] =?UTF-8?q?FE-747:=20Declarative=20output=20arcs=20?= =?UTF-8?q?=E2=80=94=20Guard=20predicates=20on=20HandlerDescriptor.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move conditional output routing from wireHandlers fire closures into typed Guard predicates declared on HandlerDescriptor. ActionDescriptor gains a required guard:Guard (replacing routeField); RunTestsDescriptor adds passGuard; AssessSemanticDescriptor adds satisfiedGuard. wireHandlers consumes them via a pure evalGuard(guard, report) helper. Adds enumerateCandidateOutputs(transition) so static consumers can derive the reachable output-place set per transition from topology alone, without instantiating actions, reports, or the test runner. Halt paths (budget exhaustion, verify-epic failure) and token transforms stay in fire closures and remain follow-on slices. New invariant I125-K. Engine contract suite unchanged (84 orchestrator tests pass); npm run verify green. Co-Authored-By: Claude --- memory/PLAN.md | 13 ++- memory/SPEC.md | 1 + src/orchestrator/src/net-blueprint.ts | 82 +++++++++++++-- src/orchestrator/src/net-compiler.ts | 37 ++++--- src/orchestrator/src/topology.test.ts | 143 ++++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 30 deletions(-) create mode 100644 src/orchestrator/src/topology.test.ts diff --git a/memory/PLAN.md b/memory/PLAN.md index 9345ea6a..1206cc7d 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -17,7 +17,7 @@ The interaction model is mature: four-phase interview, interviewer-autonomous qu The next product arc is the **Conversational Workspace Runtime** umbrella (`docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md`) plus a stronger semantic/generative substrate. The umbrella synthesizes MULTI_CHAT, SIDE_CHAT, PATCH_LEDGER, and CONTINUOUS_WORKSPACE_HYBRID into five sub-tracks: workspace shell (Track 1, shipped as `continuous-workspace` / FE-709), inline secondary-chat runtime over the existing chat/turn substrate (`chat-runtime-secondary-chats`), reconciliation runtime absorption (`reconciliation-runtime`), changeset ledger (`changeset-ledger`), and transcript-first chat context provision (`chat-context-provision`). The shell is now the stable host; schema-level `thread` is deferred until chat/turn proves insufficient. Secondary chats are the near-term runtime primitive for side, reconciliation, qa, and strategy conversations. The chat runtime is the critical unblocker for reconciliation absorption; chat context provision can proceed against chat/turn with explicit transcript snapshots and graph-item handles. The changeset ledger runs in parallel. The umbrella supersedes the independent side-chat V4a persistence horizon — persistent side-chat history becomes inline secondary chats in the workspace. The FE-705 branch contributes an integration substrate — a local agent capability CLI and external LLM-as-user probe harness — that should be reconciled into main before graph-review and scenario-options work depends on generated completed-spec fixtures. After that, the highest-coordination work is intent-graph semantics and the semantic changeset ledger; FE-701 should follow soon after the FE-705 reconciliation because the current schema already carries transitional multi-chat / reconciliation placeholders that only become coherent once `changeset` / `change` owns semantic mutation history. Lower-coordination provider, gitignore, and web-research work can proceed in parallel. -The **orchestrator / Petri-net execution substrate** is committed (2026-05-21) to Petri as the forward execution model, justified by parallelism, simulation, and resume value claims. Phases 0–2 are done: the dual-engine PoC (Phase 0, FE-730) validated the substrate and extracted the compiler/interpreter; Phase 1 (FE-738) added two-lane mechanical+semantic subnets, the compiler topology/wiring split, and §7 event vocabulary; Phase 2 (FE-743) added parallel firing policy with greedy token claiming, shared resource pool tokens bounding global concurrency, and worktree-per-slice isolation — the decision gate passed (parallel measurably beats serial on wall clock). Phase 3 (graph compilation) remains blocked on `intent-graph-semantics` (FE-700) for relation-policy gates; the Phase-3-prep step `petri-declarative-routing` is FE-700-independent and slots in next (moves conditional routing from fire closures into typed Guard predicates on `HandlerDescriptor`, the structural prerequisite for any static analysis including Phase 4 simulation). The north-star design is `docs/next/architecture/plan-graph-petri-orchestration.md`. +The **orchestrator / Petri-net execution substrate** is committed (2026-05-21) to Petri as the forward execution model, justified by parallelism, simulation, and resume value claims. Phases 0–2 are done: the dual-engine PoC (Phase 0, FE-730) validated the substrate and extracted the compiler/interpreter; Phase 1 (FE-738) added two-lane mechanical+semantic subnets, the compiler topology/wiring split, and §7 event vocabulary; Phase 2 (FE-743) added parallel firing policy with greedy token claiming, shared resource pool tokens bounding global concurrency, and worktree-per-slice isolation — the decision gate passed (parallel measurably beats serial on wall clock). Phase-3-prep `petri-declarative-routing` (FE-747) is done: typed Guard predicates on `HandlerDescriptor` plus `enumerateCandidateOutputs` make topology-only enumeration of reachable output places possible (I125-K). Phase 3 (graph compilation) remains blocked on `intent-graph-semantics` (FE-700) for relation-policy gates; Phase 4 (simulation oracle) now has its routing-side structural prerequisite satisfied but still needs Phase 3 for graph-derived gates. The north-star design is `docs/next/architecture/plan-graph-petri-orchestration.md`. The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agent-mutation design notes are reconciled into one direction. `docs/design/MULTI_CHAT.md` is the substrate document. `docs/design/SIDE_CHAT.md` describes side-chat V1 / V2 / V3.0 / V3.1 / V4 phasing on top of that substrate. `docs/design/PATCH_LEDGER.md` remains historical deeper design pressure for semantic mutation history, but canonical future-facing vocabulary is `changeset` / `change`. The product-layer ontology trajectory is split out as `docs/design/INTENT_GRAPH_SEMANTICS.md` and `docs/design/BEHAVIORAL_KERNELS.md`; broader synthesis lives in `docs/archive/design/INTENT_SPEC_EVOLUTION.md`. FE-705's branch-local strategy/proposal notes add scenario options, graph-review oracle, chat-local strategies, and concern/dependency mapping; those notes should become a canonical design doc when the branch is integrated. Coordination uses a substrate-strangler posture: keep existing frontend REST/SSE contracts stable while route adapters and capability adapters converge on shared server-owned handlers, then cut over UI flows only after parity and changeset-backed authority exist. The dev-layer self-tooling trajectory lives in `docs/design/ln-skills/EVOLUTION.md`. @@ -27,13 +27,12 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen 1. `agent-fixture-substrate` — branch-complete off main, reconciling — FE-705 integration substrate for JSONL agent capability CLI and LLM-as-user probes. 2. `chat-runtime-secondary-chats` — FE-716; V1 done — PR #141 merged to main. -3. `petri-declarative-routing` — new — Phase-3-prep slice that decouples topology from runtime routing closures; stacks off FE-745. ### Recently Completed +- `petri-declarative-routing` (FE-747) — `HandlerDescriptor` branching transitions now carry typed `Guard` predicates (`always`, `reportFieldTruthy`); `wireHandlers` consumes them via `evalGuard`; new `enumerateCandidateOutputs(transition)` exposes the topology-derived output-place set per transition. Establishes I125-K. Structural prerequisite for `petri-simulation-oracle` (Phase 4) and any static analysis; FE-700-independent. Halt paths and token transforms remain runtime concerns (separate follow-on slices). Follows FE-745. - `petri-epic-verification-merge` — `verify-epic` now runs against a freshly-merged `/__epic__//` built from completed slice worktrees (declaration-order wins on path collisions; conflicts surfaced via `epic-sandbox-merged` event). Unblocks multi-slice `cook` runs. Follows FE-743. - `petri-parallel-execution` (FE-743) — parallel firing policy, shared resource pool tokens, worktree-per-slice isolation. Decision gate passed: parallel measurably beats serial on wall clock for multi-slice plans. Follows `petri-semantic-lanes` (FE-738). -- `petri-semantic-lanes` (FE-738) — two-lane subnet, compiler topology/wiring split, engine factory, semantic rework budget, §7 events. PR #148. Criterion (5) stale-graph deferred → `petri-graph-compilation`. ### Next @@ -128,9 +127,9 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### petri-declarative-routing - **Name:** Petri declarative output arcs — topology-level routing for branching transitions -- **Linear:** unassigned in this plan snapshot +- **Linear:** FE-747 - **Kind:** structural -- **Status:** not-started +- **Status:** done - **Objective:** Move conditional output routing from `wireHandlers` fire closures into typed `Guard` predicates declared on `HandlerDescriptor`, so a topology-only consumer can enumerate every reachable output place per transition without invoking actions, reports, or the test runner. - **Why now / unlocks:** First Phase-3 prep step that does not depend on FE-700. Today's `HandlerDescriptor` already names candidate output places (`onTrue`/`onFalse`, `onPass`/`onFail`, `onSatisfied`/`onRejected`) but the guard predicates that select among them live in runtime closures in `net-compiler.ts`. Without declarative guards, formal analyses (reachability, deadlock detection, simulation) can only see input-side structure — which makes `petri-simulation-oracle` (Phase 4) impossible regardless of whether Phase 3 graph compilation lands. Token transforms (`reportId` attach, budget decrement, retry/rework propagation) and budget-exhaustion halts stay in closures and become separate follow-on slices. - **Acceptance:** (1) `HandlerDescriptor` branches that route conditionally carry typed `Guard` data; initial vocabulary covers `always` and `reportFieldTruthy`; extension shape is documented. (2) `wireHandlers` consumes guards via a pure `evalGuard(guard, report)` helper; conditional routing logic moves out of inline closure code. (3) A pure `enumerateCandidateOutputs(transition: TransitionSkeleton): Set` function returns the topology-derived output place set per transition without instantiating actions/reports/testRunner. (4) Engine contract suite passes unchanged across both engines. @@ -520,9 +519,9 @@ orchestrator-poc (Phase 0: compiler extraction — done) └──→ petri-semantic-lanes (Phase 1: two-lane subnet + §7 events — done) └──→ petri-parallel-execution (Phase 2: concurrent firing + resource pools — done) ├──→ petri-epic-verification-merge (hardening: merge slice worktrees for verify-epic — done) - └──→ petri-declarative-routing (Phase-3-prep: topology-level Guard predicates; FE-700-independent) + └──→ petri-declarative-routing (Phase-3-prep: topology-level Guard predicates; FE-700-independent — done) ├──→ petri-graph-compilation (Phase 3: compile from plan-graph + relation policy; needs FE-700) - └──→ petri-simulation-oracle (Phase 4: reachability, deadlock, resume; declarative routing is the structural prerequisite for static analysis; Phase 3 still needed for graph-derived gates) + └──→ petri-simulation-oracle (Phase 4: reachability, deadlock, resume; declarative-routing structural prerequisite now satisfied; Phase 3 still needed for graph-derived gates) LOWER-PRIORITY / DEFERRED side-chat-v4b-item-versioning (depends on changeset-ledger) diff --git a/memory/SPEC.md b/memory/SPEC.md index a9836038..c4cafc1c 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -257,6 +257,7 @@ Each invariant is a formalization candidate: the property is stated in human lan | I122-K | Orchestrator event content lives in `reports.jsonl`; petri engine tokens carry only `{ reportId, sliceId, epicId }` pointers. Proc engine may pass data through normal function calls — the shared seam is inputs and outputs. | contract tests | Requirement 48; D156-K | | I123-K | Worktree isolation holds — fixture directory and source repo are never mutated by an orchestrator run; worktree is cwd-scoped at `/.cook/runs//worktree/`. | integration tests, worktree.test.ts | Requirement 49; D159-K | | I124-K | Epic verification runs against a freshly-rebuilt `/__epic__//` dir holding the deterministic merge of its completed slices' worktrees (later slices in plan declaration order overwrite earlier ones on path collisions; collisions are reported via the `epic-sandbox-merged` event). Per-slice worktrees are not mutated by the merge. | epic-sandbox-merge.test.ts, engine-contract.test.ts | Requirement 49; D159-K | +| I125-K | Topology output-place candidates are fully declared in `HandlerDescriptor` via typed `Guard` predicates; `wireHandlers` introduces no new output places at fire time. Pure consumers can enumerate the reachable output-place set per transition from topology data alone via `enumerateCandidateOutputs(transition)`. Halt paths (budget exhaustion, verify-epic failure) and token transforms (reportId attach, retry/rework count propagation) remain runtime concerns and are explicitly not covered by this invariant. | topology.test.ts, engine-contract.test.ts | Requirements 46, 47, 48; D155-K (FE-747) | ## Future Direction Register diff --git a/src/orchestrator/src/net-blueprint.ts b/src/orchestrator/src/net-blueprint.ts index 70286e65..f1762256 100644 --- a/src/orchestrator/src/net-blueprint.ts +++ b/src/orchestrator/src/net-blueprint.ts @@ -4,6 +4,28 @@ // --------------------------------------------------------------------------- import type { TransitionContract } from './petri-net.js'; +import type { ReportLine } from './types.js'; + +// --------------------------------------------------------------------------- +// Guard — declarative routing predicate evaluated against a report payload +// +// Extension shape: add a new `kind` variant here and a matching case in +// evalGuard. Keep guards pure data so a static analyzer can reason about +// reachable markings without executing fire closures. +// --------------------------------------------------------------------------- + +export type Guard = { kind: 'always' } | { kind: 'reportFieldTruthy'; field: string }; + +export function evalGuard(guard: Guard, report: ReportLine | undefined): boolean { + switch (guard.kind) { + case 'always': + return true; + case 'reportFieldTruthy': { + const payload = report?.payload as Record | undefined; + return !!payload?.[guard.field]; + } + } +} // --------------------------------------------------------------------------- // Token identity for initial token seeding and output routing @@ -27,7 +49,7 @@ type PassthroughDescriptor = { }; /** - * Call an action handler, optionally route on a report payload field. + * Call an action handler, route declaratively on guard evaluation. * Covers: evaluate, write-tests, write-code. */ type ActionDescriptor = { @@ -35,33 +57,37 @@ type ActionDescriptor = { actionKey: string; sliceId: string; epicId: string; - /** If set, read report.payload[routeField] to decide routing. */ - routeField?: string; - /** Places to emit to when routeField is truthy (or always, if no routeField). */ + /** Guard evaluated against the action's report; selects onTrue vs onFalse. */ + guard: Guard; + /** Places to emit to when guard evaluates true. */ onTrue: string[]; - /** Places to emit to when routeField is falsy. */ + /** Places to emit to when guard evaluates false. */ onFalse: string[]; /** Place to return a fresh agent-resource token to. */ agentReturnPlace?: string; }; -/** Test runner with retry budget — 3-way routing. */ +/** Test runner with retry budget — 3-way routing on declarative guard. */ type RunTestsDescriptor = { kind: 'run-tests'; sliceId: string; epicId: string; target: string; + /** Guard evaluated against the tests-run report; selects onPass vs onFail. */ + passGuard: Guard; onPass: string[]; onFail: string[]; budgetPlace: string; }; -/** Semantic assessment with rework budget. */ +/** Semantic assessment with rework budget; routing is declarative. */ type AssessSemanticDescriptor = { kind: 'assess-semantic'; actionKey: string; sliceId: string; epicId: string; + /** Guard evaluated against the semantic-assessed report; selects onSatisfied vs onRejected. */ + satisfiedGuard: Guard; onSatisfied: string[]; onRejected: string[]; budgetPlace: string; @@ -125,3 +151,45 @@ export type NetBlueprint = { transitions: TransitionSkeleton[]; initialTokens: { place: string; token: TokenSeed }[]; }; + +// --------------------------------------------------------------------------- +// enumerateCandidateOutputs — topology-only enumeration of reachable +// output places for one transition. Pure: no actions, no reports, no runner. +// Used by static analyzers (reachability, deadlock detection, simulation). +// --------------------------------------------------------------------------- + +export function enumerateCandidateOutputs(transition: TransitionSkeleton): Set { + const out = new Set(); + const h = transition.handler; + switch (h.kind) { + case 'passthrough': + for (const o of h.outputs) out.add(o.place); + return out; + case 'action': + for (const p of h.onTrue) out.add(p); + for (const p of h.onFalse) out.add(p); + if (h.agentReturnPlace) out.add(h.agentReturnPlace); + return out; + case 'run-tests': + for (const p of h.onPass) out.add(p); + for (const p of h.onFail) out.add(p); + out.add(h.budgetPlace); + return out; + case 'assess-semantic': + for (const p of h.onSatisfied) out.add(p); + for (const p of h.onRejected) out.add(p); + out.add(h.budgetPlace); + return out; + case 'complete-slice': + out.add(h.completedPlace); + for (const p of h.depSignals) out.add(p); + return out; + case 'complete-epic': + out.add(h.donePlace); + for (const p of h.depSignals) out.add(p); + return out; + case 'verify-epic': + for (const o of h.onPassOutputs) out.add(o.place); + return out; + } +} diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 9a2100ce..00a02fc2 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -13,6 +13,7 @@ import { seedSliceSandboxFromDeps, sliceIdsForEpicVerifyMerge, } from './epic-sandbox-merge.js'; +import { evalGuard } from './net-blueprint.js'; import type { NetBlueprint, TokenSeed, TransitionSkeleton } from './net-blueprint.js'; import { PetriNet } from './petri-net.js'; import type { Token } from './petri-net.js'; @@ -154,7 +155,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { actionKey: 'evaluate-done', sliceId: sid, epicId: epic.id, - routeField: 'done', + guard: { kind: 'reportFieldTruthy', field: 'done' }, onTrue: [p(sid, 'done-spec')], onFalse: [p(sid, 'needs-more')], agentReturnPlace: poolTestAgent, @@ -171,6 +172,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { actionKey: 'write-tests', sliceId: sid, epicId: epic.id, + guard: { kind: 'always' }, onTrue: [p(sid, 'failing-tests')], onFalse: [], agentReturnPlace: poolTestAgent, @@ -187,6 +189,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { actionKey: 'write-code', sliceId: sid, epicId: epic.id, + guard: { kind: 'always' }, onTrue: [p(sid, 'untested-code')], onFalse: [], agentReturnPlace: poolCodeAgent, @@ -208,6 +211,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { sliceId: sid, epicId: epic.id, target: slice.verification[0]?.target ?? '', + passGuard: { kind: 'reportFieldTruthy', field: 'passed' }, onPass: [p(sid, 'spec-ready')], onFail: [p(sid, 'failing-tests')], budgetPlace: p(sid, 'retry-budget'), @@ -230,6 +234,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { actionKey: 'assess-semantic', sliceId: sid, epicId: epic.id, + satisfiedGuard: { kind: 'reportFieldTruthy', field: 'satisfied' }, onSatisfied: [p(sid, 'semantic-satisfied')], onRejected: [p(sid, 'needs-more')], budgetPlace: p(sid, 'semantic-budget'), @@ -347,7 +352,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, } case 'action': { - const { actionKey, sliceId, epicId, routeField, onTrue, onFalse, agentReturnPlace } = h; + const { actionKey, sliceId, epicId, guard, onTrue, onFalse, agentReturnPlace } = h; const slice = plan.slices.find((s) => s.id === sliceId)!; const epic = plan.epics.find((e) => e.id === epicId)!; const baseToken: Token = { sliceId, epicId }; @@ -366,14 +371,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, ctx.reportIds.push(reportId); const tok: Token = { ...consumed[0]!, reportId }; - let route: string[]; - if (routeField) { - const report = reports.getById(reportId); - const val = !!(report?.payload as Record)?.[routeField]; - route = val ? onTrue : onFalse; - } else { - route = onTrue; - } + const route = evalGuard(guard, reports.getById(reportId)) ? onTrue : onFalse; const outputs: { place: string; token: Token }[] = route.map((pl) => ({ place: pl, token: tok })); if (agentReturnPlace) { @@ -385,7 +383,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, } case 'run-tests': { - const { sliceId, epicId, target, onPass, onFail, budgetPlace } = h; + const { sliceId, epicId, target, passGuard, onPass, onFail, budgetPlace } = h; const baseToken: Token = { sliceId, epicId }; fire = async (consumed) => { @@ -407,7 +405,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, ctx.reportIds.push(reportId); const tok: Token = { ...consumed[0]!, reportId }; - if (result.passed) { + if (evalGuard(passGuard, reports.getById(reportId))) { return [ ...onPass.map((pl) => ({ place: pl, token: tok })), { place: budgetPlace, token: { ...baseToken, retryCount: 0 } }, @@ -428,7 +426,16 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, } case 'assess-semantic': { - const { actionKey, sliceId, epicId, onSatisfied, onRejected, budgetPlace, maxReworks } = h; + const { + actionKey, + sliceId, + epicId, + satisfiedGuard, + onSatisfied, + onRejected, + budgetPlace, + maxReworks, + } = h; const slice = plan.slices.find((s) => s.id === sliceId)!; const epic = plan.epics.find((e) => e.id === epicId)!; const baseToken: Token = { sliceId, epicId }; @@ -448,10 +455,8 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, }; const reportId = await actions[actionKey]!(actCtx); ctx.reportIds.push(reportId); - const report = reports.getById(reportId); - const satisfied = !!(report?.payload as { satisfied?: boolean })?.satisfied; - if (satisfied) { + if (evalGuard(satisfiedGuard, reports.getById(reportId))) { return onSatisfied.map((pl) => ({ place: pl, token: { ...consumed[0]!, reportId } })); } if (reworkCount >= maxReworks) { diff --git a/src/orchestrator/src/topology.test.ts b/src/orchestrator/src/topology.test.ts new file mode 100644 index 00000000..858b8cec --- /dev/null +++ b/src/orchestrator/src/topology.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +import { enumerateCandidateOutputs, evalGuard } from './net-blueprint.js'; +import { compileTopology } from './net-compiler.js'; +import type { Plan, ReportLine } from './types.js'; + +// --------------------------------------------------------------------------- +// evalGuard — pure interpreter for declarative routing guards +// --------------------------------------------------------------------------- + +function makeReport(payload: Record): ReportLine { + return { + id: 'rpt-x', + ts: '2026-05-26T00:00:00.000Z', + epicId: 'epic-1', + sliceId: 'slice-1', + actor: 'test', + event: 'test', + payload, + }; +} + +describe('evalGuard', () => { + it('always returns true regardless of report', () => { + expect(evalGuard({ kind: 'always' }, makeReport({ done: false }))).toBe(true); + expect(evalGuard({ kind: 'always' }, makeReport({}))).toBe(true); + expect(evalGuard({ kind: 'always' }, undefined)).toBe(true); + }); + + it('reportFieldTruthy reads the named field and coerces to boolean', () => { + const guard = { kind: 'reportFieldTruthy', field: 'done' } as const; + expect(evalGuard(guard, makeReport({ done: true }))).toBe(true); + expect(evalGuard(guard, makeReport({ done: false }))).toBe(false); + expect(evalGuard(guard, makeReport({ done: 'yes' }))).toBe(true); + expect(evalGuard(guard, makeReport({ done: 0 }))).toBe(false); + expect(evalGuard(guard, makeReport({ other: true }))).toBe(false); + }); + + it('reportFieldTruthy returns false when the report is missing', () => { + const guard = { kind: 'reportFieldTruthy', field: 'done' } as const; + expect(evalGuard(guard, undefined)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// enumerateCandidateOutputs — pure topology consumer +// --------------------------------------------------------------------------- + +const simplePlan: Plan = { + epics: [{ id: 'epic-1', summary: 'E', depends_on: [], verification: [] }], + slices: [ + { + id: 'slice-1', + epic_id: 'epic-1', + definition: 'D', + depends_on: [], + verification: [{ kind: 'unit-test', target: 't' }], + }, + ], +}; + +const depPlan: Plan = { + epics: [{ id: 'epic-1', summary: 'E', depends_on: [], verification: [] }], + slices: [ + { + id: 'slice-a', + epic_id: 'epic-1', + definition: 'A', + depends_on: [], + verification: [{ kind: 'unit-test', target: 'ta' }], + }, + { + id: 'slice-b', + epic_id: 'epic-1', + definition: 'B', + depends_on: ['slice-a'], + verification: [{ kind: 'unit-test', target: 'tb' }], + }, + ], +}; + +describe('enumerateCandidateOutputs', () => { + it('returns a non-empty output set for every transition in simplePlan', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + for (const transition of blueprint.transitions) { + const outputs = enumerateCandidateOutputs(transition); + expect(outputs.size, `${transition.id} has empty output set`).toBeGreaterThan(0); + } + }); + + it('returns only declared places (no runtime synthesis)', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const declaredPlaces = new Set(blueprint.places); + for (const transition of blueprint.transitions) { + for (const place of enumerateCandidateOutputs(transition)) { + expect(declaredPlaces.has(place), `${transition.id} emits to undeclared place ${place}`).toBe(true); + } + } + }); + + it('action transitions enumerate the union of onTrue, onFalse, and agentReturnPlace', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const writeTests = blueprint.transitions.find((t) => t.id.endsWith(':write-tests')); + expect(writeTests).toBeDefined(); + const handler = writeTests!.handler; + if (handler.kind !== 'action') throw new Error('expected action descriptor'); + + const expected = new Set([...handler.onTrue, ...handler.onFalse]); + if (handler.agentReturnPlace) expected.add(handler.agentReturnPlace); + + expect(enumerateCandidateOutputs(writeTests!)).toEqual(expected); + }); + + it('run-tests transitions enumerate onPass, onFail, and budgetPlace', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const runTests = blueprint.transitions.find((t) => t.id.endsWith(':run-tests')); + expect(runTests).toBeDefined(); + const handler = runTests!.handler; + if (handler.kind !== 'run-tests') throw new Error('expected run-tests descriptor'); + + const expected = new Set([...handler.onPass, ...handler.onFail, handler.budgetPlace]); + expect(enumerateCandidateOutputs(runTests!)).toEqual(expected); + }); + + it('assess-semantic transitions enumerate onSatisfied, onRejected, and budgetPlace', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const assess = blueprint.transitions.find((t) => t.id.endsWith(':assess-semantic')); + expect(assess).toBeDefined(); + const handler = assess!.handler; + if (handler.kind !== 'assess-semantic') throw new Error('expected assess-semantic descriptor'); + + const expected = new Set([...handler.onSatisfied, ...handler.onRejected, handler.budgetPlace]); + expect(enumerateCandidateOutputs(assess!)).toEqual(expected); + }); + + it('depPlan: dep-signal places are reachable from complete-slice topology', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const completeA = blueprint.transitions.find((t) => t.id === 'slice-a:return-done'); + expect(completeA).toBeDefined(); + const outputs = enumerateCandidateOutputs(completeA!); + expect(outputs.has('slice:slice-a:dep-signal:slice-b')).toBe(true); + }); +}); From 7c54fa8b6f5bbd98fde7ca3cf2e13c2dd33649cf Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 26 May 2026 01:37:44 +0200 Subject: [PATCH 3/5] Rename Guard to RouteGuard, evalGuard to evalRouteGuard. Disambiguates the typed routing predicate from TransitionContract.guard, the pre-existing human-readable note string on the same record. Pure rename across net-blueprint.ts (type + interpreter), net-compiler.ts (consumer), and topology.test.ts. Descriptor field names (passGuard, satisfiedGuard, ActionDescriptor.guard) keep their domain identifiers. No behavior change; 84 orchestrator tests pass. Co-Authored-By: Claude --- memory/REFACTOR.md | 43 +++++++++++++++++++++++++++ src/orchestrator/src/net-blueprint.ts | 20 ++++++------- src/orchestrator/src/net-compiler.ts | 8 ++--- src/orchestrator/src/topology.test.ts | 24 +++++++-------- 4 files changed, 69 insertions(+), 26 deletions(-) create mode 100644 memory/REFACTOR.md diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md new file mode 100644 index 00000000..d74f6e77 --- /dev/null +++ b/memory/REFACTOR.md @@ -0,0 +1,43 @@ + + +## Problem Statement + +Two cosmetic issues surfaced in `/ln-review` of the FE-747 declarative-routing slice. Neither is load-bearing, but both make the topology layer slightly harder to read or to trust: + +1. **The typed `Guard` predicate added in FE-747 collides with `TransitionContract.guard`, a pre-existing human-readable note string on the same record.** Same word, two meanings, both in `net-blueprint.ts`. Future readers will conflate. +2. **The `enumerateCandidateOutputs` tests for branching descriptors compute their expected set from the same descriptor fields the function consumes** (`handler.onTrue ∪ handler.onFalse ∪ ...`). This catches typos but would silently pass if both the descriptor emitter and the enumerator dropped a branch in lockstep — there is no behavioral anchor pinning what the function should actually return for a known fixture. + +## Solution + +After the refactor: + +1. The typed routing predicate is named `RouteGuard` (with interpreter `evalRouteGuard`). The string note on `TransitionContract` keeps its `guard` field. No more name collision. +2. `topology.test.ts` carries at least one golden-style assertion that pins the expected output set of a specific transition (e.g. `slice-1:evaluate` in the simplePlan fixture) against hand-written, literal place names — independent of descriptor field shape. + +## Commits + +Each commit leaves the codebase working (full `npm run verify` green). + +1. **Rename `Guard` → `RouteGuard` and `evalGuard` → `evalRouteGuard` throughout** — pure mechanical rename in `net-blueprint.ts` (type + interpreter + every descriptor field that references the type), `net-compiler.ts` (import + every consumer), and `topology.test.ts` (import + every call site). No behavior change. Run the orchestrator suite to confirm equivalence. +2. **Add golden-fixture assertions to `topology.test.ts` that pin literal expected output sets for representative branching transitions** — at minimum the action transition (`slice-1:evaluate`) and the run-tests transition. Expected sets are written as string literals, not computed from descriptor fields, so future drift in either the descriptor emitter or the enumerator surfaces immediately. Existing union-equality tests stay (they prove pairwise consistency); the goldens add the anchor. + +## Decisions + +- **No new module extraction.** `RouteGuard`, `evalRouteGuard`, and `enumerateCandidateOutputs` stay in `net-blueprint.ts`. The seam is settled. +- **No change to `TransitionContract.guard`.** It remains a human-readable string note. Renaming it would touch every transition emission site in `net-compiler.ts` for cosmetic gain; not worth the diff. +- **Golden fixtures live inline in `topology.test.ts`**, not in a separate snapshot file. Two or three literal-string assertions are not enough to justify a snapshot system. +- **No update to SPEC.md `I125-K`.** The invariant text already correctly describes the property; only the symbol names change. + +## Testing Decisions + +- Behavior under audit is "given this topology, the enumerator returns these places". The good test asserts the result for a known input — not the relationship between two implementation details. +- Module under test is `enumerateCandidateOutputs` (and `evalRouteGuard` after rename), through their public exports. +- Prior art: `engine-contract.test.ts` "Adapter: compiled net shape" section pins transition counts and contract metadata against the same `simplePlan` / `depPlan` fixtures with literal expected values. Topology tests should mirror that style for output-place enumeration. + +## Out of Scope + +- The other `/ln-review` findings (model tightening for `RouteGuard.always` + empty `onFalse`, verify-epic guard parity, halt-arc declarativity, file-comment tightening) — all deferred or below the action threshold. +- Any change to `wireHandlers` runtime behavior. +- Any change to existing engine-contract tests. +- Documentation generation or design-doc updates. diff --git a/src/orchestrator/src/net-blueprint.ts b/src/orchestrator/src/net-blueprint.ts index f1762256..e4a50ef5 100644 --- a/src/orchestrator/src/net-blueprint.ts +++ b/src/orchestrator/src/net-blueprint.ts @@ -7,16 +7,16 @@ import type { TransitionContract } from './petri-net.js'; import type { ReportLine } from './types.js'; // --------------------------------------------------------------------------- -// Guard — declarative routing predicate evaluated against a report payload +// RouteGuard — declarative routing predicate evaluated against a report payload // // Extension shape: add a new `kind` variant here and a matching case in -// evalGuard. Keep guards pure data so a static analyzer can reason about +// evalRouteGuard. Keep guards pure data so a static analyzer can reason about // reachable markings without executing fire closures. // --------------------------------------------------------------------------- -export type Guard = { kind: 'always' } | { kind: 'reportFieldTruthy'; field: string }; +export type RouteGuard = { kind: 'always' } | { kind: 'reportFieldTruthy'; field: string }; -export function evalGuard(guard: Guard, report: ReportLine | undefined): boolean { +export function evalRouteGuard(guard: RouteGuard, report: ReportLine | undefined): boolean { switch (guard.kind) { case 'always': return true; @@ -57,8 +57,8 @@ type ActionDescriptor = { actionKey: string; sliceId: string; epicId: string; - /** Guard evaluated against the action's report; selects onTrue vs onFalse. */ - guard: Guard; + /** RouteGuard evaluated against the action's report; selects onTrue vs onFalse. */ + guard: RouteGuard; /** Places to emit to when guard evaluates true. */ onTrue: string[]; /** Places to emit to when guard evaluates false. */ @@ -73,8 +73,8 @@ type RunTestsDescriptor = { sliceId: string; epicId: string; target: string; - /** Guard evaluated against the tests-run report; selects onPass vs onFail. */ - passGuard: Guard; + /** RouteGuard evaluated against the tests-run report; selects onPass vs onFail. */ + passGuard: RouteGuard; onPass: string[]; onFail: string[]; budgetPlace: string; @@ -86,8 +86,8 @@ type AssessSemanticDescriptor = { actionKey: string; sliceId: string; epicId: string; - /** Guard evaluated against the semantic-assessed report; selects onSatisfied vs onRejected. */ - satisfiedGuard: Guard; + /** RouteGuard evaluated against the semantic-assessed report; selects onSatisfied vs onRejected. */ + satisfiedGuard: RouteGuard; onSatisfied: string[]; onRejected: string[]; budgetPlace: string; diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 00a02fc2..ebf746a5 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -13,7 +13,7 @@ import { seedSliceSandboxFromDeps, sliceIdsForEpicVerifyMerge, } from './epic-sandbox-merge.js'; -import { evalGuard } from './net-blueprint.js'; +import { evalRouteGuard } from './net-blueprint.js'; import type { NetBlueprint, TokenSeed, TransitionSkeleton } from './net-blueprint.js'; import { PetriNet } from './petri-net.js'; import type { Token } from './petri-net.js'; @@ -371,7 +371,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, ctx.reportIds.push(reportId); const tok: Token = { ...consumed[0]!, reportId }; - const route = evalGuard(guard, reports.getById(reportId)) ? onTrue : onFalse; + const route = evalRouteGuard(guard, reports.getById(reportId)) ? onTrue : onFalse; const outputs: { place: string; token: Token }[] = route.map((pl) => ({ place: pl, token: tok })); if (agentReturnPlace) { @@ -405,7 +405,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, ctx.reportIds.push(reportId); const tok: Token = { ...consumed[0]!, reportId }; - if (evalGuard(passGuard, reports.getById(reportId))) { + if (evalRouteGuard(passGuard, reports.getById(reportId))) { return [ ...onPass.map((pl) => ({ place: pl, token: tok })), { place: budgetPlace, token: { ...baseToken, retryCount: 0 } }, @@ -456,7 +456,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, const reportId = await actions[actionKey]!(actCtx); ctx.reportIds.push(reportId); - if (evalGuard(satisfiedGuard, reports.getById(reportId))) { + if (evalRouteGuard(satisfiedGuard, reports.getById(reportId))) { return onSatisfied.map((pl) => ({ place: pl, token: { ...consumed[0]!, reportId } })); } if (reworkCount >= maxReworks) { diff --git a/src/orchestrator/src/topology.test.ts b/src/orchestrator/src/topology.test.ts index 858b8cec..f692d07c 100644 --- a/src/orchestrator/src/topology.test.ts +++ b/src/orchestrator/src/topology.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { enumerateCandidateOutputs, evalGuard } from './net-blueprint.js'; +import { enumerateCandidateOutputs, evalRouteGuard } from './net-blueprint.js'; import { compileTopology } from './net-compiler.js'; import type { Plan, ReportLine } from './types.js'; // --------------------------------------------------------------------------- -// evalGuard — pure interpreter for declarative routing guards +// evalRouteGuard — pure interpreter for declarative routing guards // --------------------------------------------------------------------------- function makeReport(payload: Record): ReportLine { @@ -20,25 +20,25 @@ function makeReport(payload: Record): ReportLine { }; } -describe('evalGuard', () => { +describe('evalRouteGuard', () => { it('always returns true regardless of report', () => { - expect(evalGuard({ kind: 'always' }, makeReport({ done: false }))).toBe(true); - expect(evalGuard({ kind: 'always' }, makeReport({}))).toBe(true); - expect(evalGuard({ kind: 'always' }, undefined)).toBe(true); + expect(evalRouteGuard({ kind: 'always' }, makeReport({ done: false }))).toBe(true); + expect(evalRouteGuard({ kind: 'always' }, makeReport({}))).toBe(true); + expect(evalRouteGuard({ kind: 'always' }, undefined)).toBe(true); }); it('reportFieldTruthy reads the named field and coerces to boolean', () => { const guard = { kind: 'reportFieldTruthy', field: 'done' } as const; - expect(evalGuard(guard, makeReport({ done: true }))).toBe(true); - expect(evalGuard(guard, makeReport({ done: false }))).toBe(false); - expect(evalGuard(guard, makeReport({ done: 'yes' }))).toBe(true); - expect(evalGuard(guard, makeReport({ done: 0 }))).toBe(false); - expect(evalGuard(guard, makeReport({ other: true }))).toBe(false); + expect(evalRouteGuard(guard, makeReport({ done: true }))).toBe(true); + expect(evalRouteGuard(guard, makeReport({ done: false }))).toBe(false); + expect(evalRouteGuard(guard, makeReport({ done: 'yes' }))).toBe(true); + expect(evalRouteGuard(guard, makeReport({ done: 0 }))).toBe(false); + expect(evalRouteGuard(guard, makeReport({ other: true }))).toBe(false); }); it('reportFieldTruthy returns false when the report is missing', () => { const guard = { kind: 'reportFieldTruthy', field: 'done' } as const; - expect(evalGuard(guard, undefined)).toBe(false); + expect(evalRouteGuard(guard, undefined)).toBe(false); }); }); From 5c99ccb5a04af8a7b4ef55de1310956d6b776bbb Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 26 May 2026 01:39:15 +0200 Subject: [PATCH 4/5] Pin enumerateCandidateOutputs with literal-fixture goldens. The existing per-kind tests computed expected output sets from the same descriptor fields the enumerator consumes, so they'd pass silently if both the topology emitter and the enumerator dropped a branch in lockstep. Add three goldens that pin literal expected place names for slice-1:evaluate, slice-1:run-tests, and slice-1:assess-semantic against the simplePlan fixture. Lockstep drift now surfaces immediately. Retires memory/REFACTOR.md (FE-747 refactor pass complete). Co-Authored-By: Claude --- memory/REFACTOR.md | 43 --------------------------- src/orchestrator/src/topology.test.ts | 33 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 43 deletions(-) delete mode 100644 memory/REFACTOR.md diff --git a/memory/REFACTOR.md b/memory/REFACTOR.md deleted file mode 100644 index d74f6e77..00000000 --- a/memory/REFACTOR.md +++ /dev/null @@ -1,43 +0,0 @@ - - -## Problem Statement - -Two cosmetic issues surfaced in `/ln-review` of the FE-747 declarative-routing slice. Neither is load-bearing, but both make the topology layer slightly harder to read or to trust: - -1. **The typed `Guard` predicate added in FE-747 collides with `TransitionContract.guard`, a pre-existing human-readable note string on the same record.** Same word, two meanings, both in `net-blueprint.ts`. Future readers will conflate. -2. **The `enumerateCandidateOutputs` tests for branching descriptors compute their expected set from the same descriptor fields the function consumes** (`handler.onTrue ∪ handler.onFalse ∪ ...`). This catches typos but would silently pass if both the descriptor emitter and the enumerator dropped a branch in lockstep — there is no behavioral anchor pinning what the function should actually return for a known fixture. - -## Solution - -After the refactor: - -1. The typed routing predicate is named `RouteGuard` (with interpreter `evalRouteGuard`). The string note on `TransitionContract` keeps its `guard` field. No more name collision. -2. `topology.test.ts` carries at least one golden-style assertion that pins the expected output set of a specific transition (e.g. `slice-1:evaluate` in the simplePlan fixture) against hand-written, literal place names — independent of descriptor field shape. - -## Commits - -Each commit leaves the codebase working (full `npm run verify` green). - -1. **Rename `Guard` → `RouteGuard` and `evalGuard` → `evalRouteGuard` throughout** — pure mechanical rename in `net-blueprint.ts` (type + interpreter + every descriptor field that references the type), `net-compiler.ts` (import + every consumer), and `topology.test.ts` (import + every call site). No behavior change. Run the orchestrator suite to confirm equivalence. -2. **Add golden-fixture assertions to `topology.test.ts` that pin literal expected output sets for representative branching transitions** — at minimum the action transition (`slice-1:evaluate`) and the run-tests transition. Expected sets are written as string literals, not computed from descriptor fields, so future drift in either the descriptor emitter or the enumerator surfaces immediately. Existing union-equality tests stay (they prove pairwise consistency); the goldens add the anchor. - -## Decisions - -- **No new module extraction.** `RouteGuard`, `evalRouteGuard`, and `enumerateCandidateOutputs` stay in `net-blueprint.ts`. The seam is settled. -- **No change to `TransitionContract.guard`.** It remains a human-readable string note. Renaming it would touch every transition emission site in `net-compiler.ts` for cosmetic gain; not worth the diff. -- **Golden fixtures live inline in `topology.test.ts`**, not in a separate snapshot file. Two or three literal-string assertions are not enough to justify a snapshot system. -- **No update to SPEC.md `I125-K`.** The invariant text already correctly describes the property; only the symbol names change. - -## Testing Decisions - -- Behavior under audit is "given this topology, the enumerator returns these places". The good test asserts the result for a known input — not the relationship between two implementation details. -- Module under test is `enumerateCandidateOutputs` (and `evalRouteGuard` after rename), through their public exports. -- Prior art: `engine-contract.test.ts` "Adapter: compiled net shape" section pins transition counts and contract metadata against the same `simplePlan` / `depPlan` fixtures with literal expected values. Topology tests should mirror that style for output-place enumeration. - -## Out of Scope - -- The other `/ln-review` findings (model tightening for `RouteGuard.always` + empty `onFalse`, verify-epic guard parity, halt-arc declarativity, file-comment tightening) — all deferred or below the action threshold. -- Any change to `wireHandlers` runtime behavior. -- Any change to existing engine-contract tests. -- Documentation generation or design-doc updates. diff --git a/src/orchestrator/src/topology.test.ts b/src/orchestrator/src/topology.test.ts index f692d07c..5196951e 100644 --- a/src/orchestrator/src/topology.test.ts +++ b/src/orchestrator/src/topology.test.ts @@ -140,4 +140,37 @@ describe('enumerateCandidateOutputs', () => { const outputs = enumerateCandidateOutputs(completeA!); expect(outputs.has('slice:slice-a:dep-signal:slice-b')).toBe(true); }); + + // Goldens — literal expected sets, not derived from descriptor fields. + // These catch silent lockstep drift in both the descriptor emitter and the enumerator. + it("golden: simplePlan 'slice-1:evaluate' enumerates to the action's two routes plus pool return", () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const evaluate = blueprint.transitions.find((t) => t.id === 'slice-1:evaluate'); + expect(evaluate).toBeDefined(); + expect(enumerateCandidateOutputs(evaluate!)).toEqual( + new Set(['slice:slice-1:done-spec', 'slice:slice-1:needs-more', 'pool:test-agent']), + ); + }); + + it("golden: simplePlan 'slice-1:run-tests' enumerates to pass, fail, and retry-budget", () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const runTests = blueprint.transitions.find((t) => t.id === 'slice-1:run-tests'); + expect(runTests).toBeDefined(); + expect(enumerateCandidateOutputs(runTests!)).toEqual( + new Set(['slice:slice-1:spec-ready', 'slice:slice-1:failing-tests', 'slice:slice-1:retry-budget']), + ); + }); + + it("golden: simplePlan 'slice-1:assess-semantic' enumerates to satisfied, rejected, and semantic-budget", () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const assess = blueprint.transitions.find((t) => t.id === 'slice-1:assess-semantic'); + expect(assess).toBeDefined(); + expect(enumerateCandidateOutputs(assess!)).toEqual( + new Set([ + 'slice:slice-1:semantic-satisfied', + 'slice:slice-1:needs-more', + 'slice:slice-1:semantic-budget', + ]), + ); + }); }); From 89a128a0be7edd104f8cb16796f342064e81d051 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 27 May 2026 22:42:56 +0200 Subject: [PATCH 5/5] Fail fast on unsupported RouteGuard kinds at runtime. Unknown guard kinds now throw instead of falling through as false, so deserialized or malformed routing data cannot silently misroute tokens. Co-authored-by: Cursor --- src/orchestrator/src/net-blueprint.ts | 4 ++++ src/orchestrator/src/topology.test.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/orchestrator/src/net-blueprint.ts b/src/orchestrator/src/net-blueprint.ts index e4a50ef5..e8755d94 100644 --- a/src/orchestrator/src/net-blueprint.ts +++ b/src/orchestrator/src/net-blueprint.ts @@ -24,6 +24,10 @@ export function evalRouteGuard(guard: RouteGuard, report: ReportLine | undefined const payload = report?.payload as Record | undefined; return !!payload?.[guard.field]; } + default: { + const unknown = guard as { kind: string }; + throw new Error(`Unsupported RouteGuard kind: ${unknown.kind}`); + } } } diff --git a/src/orchestrator/src/topology.test.ts b/src/orchestrator/src/topology.test.ts index 5196951e..e7331dac 100644 --- a/src/orchestrator/src/topology.test.ts +++ b/src/orchestrator/src/topology.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { enumerateCandidateOutputs, evalRouteGuard } from './net-blueprint.js'; +import { enumerateCandidateOutputs, evalRouteGuard, type RouteGuard } from './net-blueprint.js'; import { compileTopology } from './net-compiler.js'; import type { Plan, ReportLine } from './types.js'; @@ -40,6 +40,13 @@ describe('evalRouteGuard', () => { const guard = { kind: 'reportFieldTruthy', field: 'done' } as const; expect(evalRouteGuard(guard, undefined)).toBe(false); }); + + it('throws on unsupported guard kinds', () => { + const guard = { kind: 'unknown' } as unknown as RouteGuard; + expect(() => evalRouteGuard(guard, makeReport({ done: true }))).toThrow( + 'Unsupported RouteGuard kind: unknown', + ); + }); }); // ---------------------------------------------------------------------------