Skip to main content

Hierarchical-supervisor workflow model — independent alternative proposal

Status: independent second-opinion design (Fable, xhigh reasoning). This document was produced as a deliberately independent analysis, from the source code only, to compare against a separate proposal authored in parallel by another model. It has not read that proposal or its PR. Bare module paths below (e.g. services/orchestrator.py, workflow/engine.py) live under apps/api/src/bloom/ in the monorepo.

0. TL;DR

A Bloom project is modeled as one workflow-graph instance with one current_node pointer (workflow/engine.py:29), which is structurally unable to represent steady-state execution — N tickets coding, M PRs in review, a plan revision pending, all at once. Meanwhile the backend already persists a concurrent per-entity state machine for each of those work items (engine_runs, specialist_reviews, pending_plan_change, pending_decision, pending_client_approvals). The single graph is a lossy 1-D projection of an N-dimensional reality the system already tracks.

This proposal's core claim, and where it may differ from the expected approach: the hierarchical-supervisor model should be introduced as a read model first, not as a runtime. Formalize a supervisor flow plus per-work-item child flow instances as declared specs + deterministic projections of the state that already exists, ship that end-to-end (API + UI + events), and only then — behind an ADR gate with explicit criteria — move the one genuinely imperative control-flow loop (review → rework → escalate) onto real child-flow runtime machinery. The recommended eventual runtime is the built-in engine's own reserved subworkflow node, not LangGraph subgraphs, not Temporal, and not an actor framework. The just-shipped #464 fix already chose "derive graph truth from persisted state" over "move the pointer"; this design is the systematic completion of that choice.


1. Problem statement, grounded in the code

1.1 One run, one pointer

The whole project lifecycle is a single RunState:

class RunState(BaseModel): # workflow/engine.py:29
thread_id: str
current_node: str # exactly one pointer
status: RunStatus = RunStatus.RUNNING # running | suspended | done
state: dict[str, Any]
waiting_on: str | None # node id the run is suspended on
processed_keys: list[str] # idempotency fence

It is persisted as one JSONB row per project in bloom_runs (persistence/supabase_store.py, _SCHEMA), and driven by the ~100-line built-in interpreter (workflow/engine.py:79, Engine.advance) over the declarative spec apps/server/workflows/bloom-sdlc.workflow.json.

The spec's front half is genuinely sequential and the model fits it perfectly: await_message → requirement_discovery → ask_clarification⟲ → prd_authoring → request_prd_approval → freeze_prd → milestone_planning → ticket_generation → sync_milestones → sync_tickets → notify_planning_ready.

Then the project enters steady state, and the model stops fitting:

// bloom-sdlc.workflow.json:359-366
{
"id": "await_project_event",
"type": "event_wait",
"title": "Await project activity",
"phase": "execution",
"description": "Parks the long-lived run between planning and ongoing lifecycle events
(PRs, issue changes, or a new user message)."
}

Post-planning, waiting_on == "await_project_event" for the rest of the project's life. It is even the durable definition of "active project": the scheduler discovers tickable projects with WHERE state->>'waiting_on' = 'await_project_event' (supabase_store.py, _LIST_ACTIVE; store.py, list_active_threads). The pointer never represents steady-state work; it represents the absence of a representation.

1.2 The system already tracks the N-dimensional reality

Everything that actually happens mid-project is recorded in per-entity state machines inside run.state, mutated by orchestrator code paths that never move the pointer:

Persisted field (spec decl)Keyed byPer-entry state machineWritten by
engine_runs (workflow.json:116, merge reducer)issue numberEngineRunState: ready → in_progress → pr_opened ⇄ changes_requested → done, plus blocked, failed, needs_attention; with attempt, retries, failure_kind, last_attempt_at (engine/base.py:39-55,163-213)_record_engine_run (orchestrator.py:2924), _apply_engine_report (:2885)
specialist_reviews (workflow.json:115, merge reducer)PR number{marker, round, github_output_marker} — content-addressed review rounds per PR (_run_specialist_reviews, orchestrator.py:2252)review path
pending_plan_change (workflow.json:122)singletonproposed → approved/discarded (_propose_plan_change writes it at orchestrator.py:3936; _resolve_plan_change/_apply_plan_change clear it at :3962/:3995)monitoring chat path
pending_decision (workflow.json:123) + decision_log (:124)singleton + append logdrafted → approved/declined → committed to docs/decisions/decision gates (M14)
pending_client_approvalsundeclared (see §1.4)list of {number, title, kind, pr_number, ...} sign-offs parked for the owner_park_for_client / _resolve_client_approval (orchestrator.py:1363,1525)
pending_lifecycle (workflow.json:121)singletonarchive/delete awaiting confirmationlifecycle path

Concurrency in the write path already exists too: engine runs execute as durable engine_implement jobs without the thread lock (perform_engine_implement, orchestrator.py:3177-3219, "the engine run happens WITHOUT the thread lock … the lock is taken only to record the run"), fenced by per-attempt event keys (engine_implement:{thread}:{issue}:{seq}, orchestrator.py:3156). Multiple tickets genuinely run at once; multiple PRs genuinely sit in review rounds at once.

So the premise of a hierarchical model is not "we need to build concurrent state." It is: the concurrent state exists, is durable, and is invisible in the workflow representation.

1.3 What #464 fixed, and the ceiling it hit

Commit e67bd03 (#464) made the Workflow tab honest at node granularity. _active_node_ids (orchestrator.py:5027-5055) derives the active set from persisted signals instead of the parked pointer:

  • prd.status == "revising" or pending_plan_change → lights prd_authoring
  • prd.status == "awaiting_approval" or pending_decision → lights request_prd_approval
  • any open PR in the live VCS snapshot → lights review_pull_requests
  • any open issue with status:in-progress or an eng:* label → lights track_progress
  • the bare park node reads active only when the project is genuinely idle

_node_status (orchestrator.py:5058) then colors each node active/done/pending, with done derived from a phase index (_current_phase_index, :5019, over _PHASE_ORDER, :5016) so completed phases never regress. _build_graph (:4705) serves this through project_detail (:4668) → GET /api/projects/{thread_id} (api/routes/projects.py:137), and the frontend renders exactly what it is handed — SdlcGraph (apps/web/src/components/sdlc-graph.tsx) maps node.status straight to styles, with no client-side inference. test_concurrent_activity_lights_multiple_nodes_at_once (tests/unit/test_graph_status.py:96) pins the multi-active behavior.

The ceiling: #464 lights category nodes, not instances. Three tickets implementing + two PRs in specialist review + one plan revision pending renders as three highlighted nodes — identical to one ticket + one PR + one revision. Cardinality, identity, and per-item position (attempt 2 of 3, rework round 2, escalated-awaiting-human) are all persisted (§1.2) and all invisible. That is precisely the lossy 1-D projection this design removes.

1.4 The model is already leaking

Two code facts show the declared single-graph model losing to reality, not just failing to render it:

  1. Schema drift. apply_write refuses writes to undeclared state fields (workflow/reducers.py:48-50, KeyError("write to undeclared state field")) — yet pending_client_approvals, a durable per-entity approval queue, is absent from the spec's 37 declared fields and is mutated by direct dict assignment to bypass the reducer layer (state["pending_client_approvals"] = pending, orchestrator.py:1363,1525). A whole class of concurrent work item exists outside the workflow model entirely.
  2. The engine reserves the concept but refuses it. The spec schema defines a subworkflow node type with {subworkflow, ref, input, output} config (workflows/schema/workflow.schema.json:355-365), and the built-in engine explicitly raises NotImplementedError("subworkflow nodes are not supported by this engine") (workflow/engine.py:164-165). The architecture anticipated hierarchical composition; it was never wired.

2. Target model: supervisor + child flow instances

2.1 Shape

One supervisor flow per project — the existing bloom-sdlc graph, unchanged in the front half — plus child flow instances, one per durable work item, each an instance of a small declared child spec:

project (thread_id)
└─ supervisor: bloom-sdlc (the existing RunState; pointer semantics unchanged)
├─ ticket:12 (bloom-ticket) queued → implementing → in_review ⇄ rework → done
│ ↘ blocked / failed(retrying) / escalated
├─ ticket:17 (bloom-ticket) …
├─ pr:34 (bloom-pr-review) opened → specialist_round(n) → verdict → merged/changes/escalated
├─ plan:rev-5 (bloom-plan-change) proposed → awaiting_approval → applied | discarded
├─ decision:d7 (bloom-decision) drafted → awaiting_sign_off → committed | declined
└─ approval:26 (bloom-client-approval) parked → approved | changes_requested

A child flow instance is (kind, key) → (spec_id, current_node, status, attempt/round, failure_kind, links, updated_at). The supervisor's steady-state park node gains an explicit invariant it already has implicitly (asserted in test_graph_status.py:30-34,96-104):

await_project_event is active iff the project has zero live child flow instances.

2.2 Mapping to existing state (the load-bearing table)

The child instances are a deterministic projection of state that already exists. No new write path is required to produce them:

Child kindSource of truth todayInstance keyNode derivation
ticketengine_runs[str(issue)] (+ live labels snapshot for cross-check)issue numberEngineRunState → child node 1:1: ready→queued, in_progress→implementing, pr_opened→in_review, changes_requested→rework, blocked→blocked, failed→failed (with retries vs policy → "retrying"), needs_attention→escalated, done→done
pr_reviewopen PRs in _StatusSnapshot + specialist_reviews[str(pr)] + review-round count (_count_changes_requested, cf. _apply_review_to_github, orchestrator.py:2361-2438)PR numberopened → specialist_round (round from cached marker/round) → verdict; round cap review_max_rounds (+minor extra) → escalated
plan_changepending_plan_change (+ prd.revision, decision_log)rev-{revision}non-empty → awaiting_approval; cleared with prd bump → applied; cleared without → discarded
decisionpending_decision + decision_logdecision idpending → awaiting_sign_off; in log → committed
client_approvalpending_client_approvals[]issue numberpresent → parked; resolved via _resolve_client_approval → terminal
lifecyclepending_lifecycleop namepresent → awaiting_confirmation

Two entries in this table deserve emphasis because they shape the runtime decision later:

  • The ticket child's state machine is already a formal enum with documented transitions (EngineRunState, engine/base.py:39-55 — the docstring literally narrates the graph: "readyin_progresspr_opened is the happy path…"). The child spec is a transcription, not an invention.
  • The coordination truth for tickets is GitHub labels, not run state — status:*/eng:* labels drive Coordinator.reconcile (services/coordinator.py, and the LangGraph spike services/langgraph_coordinator.py). The existing sketch calls this out as the central impedance mismatch (docs/design/langgraph-sketch.md §7: "the source of truth for ticket status is GitHub issue labels, not BloomState"). Any child-flow design that tries to make child runtime state the truth for tickets will fight the reconciler; a projection that reads both and renders disagreement (see §6.3) works with it.

2.3 What "child flow" means at each maturity level

To keep the debate honest, this design distinguishes three levels, and recommends stopping at L1 until an explicit gate is passed:

  • L0 — projected instance (read model). Child specs exist as data; instances are computed at read time from §2.2. Zero new write-path machinery.
  • L1 — declared + validated. Child specs are first-class *.workflow.json documents validated by the existing loader (workflow/spec.py:143, load_spec + validate_references); the projection's node-mapping is contract-tested against both the child spec's node ids and the source enums, so drift fails CI. Still zero write-path machinery.
  • L2 — executed child runs. Child instances become real RunState rows driven by the engine's subworkflow node; transitions are effected by the child engine rather than inferred. This is where runtime machinery enters, and it is gated (§5).

3. Runtime options analysis

Four candidate runtimes for L2 (and implicitly, for how much of L0/L1 they'd let you skip). Scores are 1–5, higher = better for Bloom specifically, judged from the code as it stands.

(a) LangGraph subgraphs

Compile the coordination loop as a LangGraph subgraph per the existing sketch (docs/design/langgraph-sketch.md §3), and model each ticket/PR as a subgraph invocation or Send-based fan-out; checkpointer for durable suspend/resume.

  • Fit 3/5. The rework loop (review → changes_requested → rework → re-review → escalate, today spread across _apply_review_to_github, _flag_ticket_for_changes, _rework_with_engine, _escalate_ticket) genuinely is a graph and would gain legibility. But LangGraph's subgraph model shares/checkpoints state along one thread; N long-lived, independently-suspending children per project pushes toward N LangGraph threads — at which point you've rebuilt the supervisor/child split yourself, on a heavier substrate. The spike's own conclusion stands: the graph models control flow, labels still hold the truth.
  • Migration cost/risk 2/5. Real rewrite of engine.py + spec loader + orchestrator dispatch (sketch §7); the WorkflowContext seam helps but HITL (interrupt()) and checkpointer adoption touch every suspend point. The existing spike (LangGraphCoordinator, parity-tested) de-risks exactly one deterministic pass — the easiest slice.
  • Operational burden 3/5. In-process library, Postgres checkpointer can reuse the pool; but a second persistence format (checkpoints) beside bloom_runs + the event store.
  • New dependencies 3/5. Already a dependency (spike), but promoted from optional flag to load-bearing.
  • Testability 4/5. Graphs are unit-testable; the spike proved parity testing works.
  • Honest-concurrent-view payoff 2/5. Almost none by itself: the view still has to be built from state, because ticket truth stays in labels/engine_runs. You pay for a runtime and still need this design's projection.

(b) Temporal parent/child workflows + signals

One parent workflow per project; child workflow per ticket/PR; webhooks become signals; retries and timeouts become Temporal policies.

  • Fit 4/5. Conceptually the cleanest match — parent/child, signals, per-child retry policies, infinite-duration workflows are exactly Temporal's shape.
  • Migration cost/risk 1/5. Total replatform. Everything Bloom just built is a hand-rolled equivalent of what Temporal provides: the durable job queue + atomic claim (persistence/jobs.py), the processed_keys/event-key idempotency fences, the scheduler heartbeat (services/scheduler.py), suspend/resume via waiting_on. Migrating means rewriting the orchestrator's ~5,200 lines of effectful paths into activities, re-solving determinism constraints, and dual-running during cutover.
  • Operational burden 1/5. A Temporal server cluster (or paid cloud) beside a currently single-Postgres deployment; new failure domain; new on-call surface.
  • New dependencies 1/5. The heaviest possible.
  • Testability 3/5. Good harnesses exist, but the test suite (unit + integration + E2E, including test_coordination_flow.py, test_plan_evolution.py) would largely need re-founding.
  • Honest-concurrent-view payoff 3/5. Temporal's UI shows child workflows — for operators. The product's Workflow tab still needs its own projection.

(c) Actor model (the "Collaborator Actor Model" direction)

Extend the M7 direction (docs/milestones/m7-specialist-review-agents.md, "M7-1 — Specialist reviewer actor model and policy") to full actors: each collaborator (or each work item) an actor with a mailbox and supervision tree.

  • Fit 2/5. Actors model who acts (roster Collaborators, domain/models.py:88) — and M7 uses the term as a policy/roster model, not a runtime. The problem here is representing work items' lifecycles, which are state machines, not mailboxes. Actor supervision/restart semantics duplicate what the job queue + EngineRunner retry policy + escalation path already provide (_escalate_engine_failure, orchestrator.py:2930; retry-then-escalate, #455/c1e094e).
  • Migration cost/risk 2/5. No mature asyncio-native actor framework to adopt (Pykka is thread-based, Ray is a cluster runtime); this means hand-rolling mailboxes/supervision — new bespoke infrastructure precisely where the engineering standards say "search before you build."
  • Operational burden 2/5, New dependencies 2/5 (either a poor-fit framework or bespoke code), Testability 3/5 (actor tests are famously order-sensitive).
  • Honest-concurrent-view payoff 2/5. Mailboxes are even less inspectable than the current dicts; the view would still be a projection.

(d) Data-driven swimlane (pragmatic middle path — no new runtime)

Exactly §2's L0/L1: declare child specs as data, project instances from persisted state at read time, render supervisor + swimlanes. Write path untouched.

  • Fit 4/5. Matches how the system already establishes truth: #464 derives node status from state; overview/digests derive from state + snapshot (_build_overview, _specialist_review_summaries, orchestrator.py:5071,5136); the reconciler derives from labels. One more derivation, at the right granularity. What it does not buy: the rework loop's control flow stays imperative in the orchestrator (that's the L2 gate's job).
  • Migration cost/risk 5/5. Additive read model + additive API field; no state migration; no behavior change; feature-flag-free rollout is plausible.
  • Operational burden 5/5, New dependencies 5/5 (none).
  • Testability 5/5. Pure functions from (run.state, snapshot) → lanes, testable in the exact style of test_graph_status.py; enum-exhaustive contract tests make drift a CI failure.
  • Honest-concurrent-view payoff 5/5. This is the honest concurrent view: every persisted work item, with identity, position, attempt/round, and failure kind.

Scorecard

FitMigration cost/riskOps burdenNew depsTestabilityHonest view
(a) LangGraph subgraphs323342
(b) Temporal411133
(c) Actor model222232
(d) Swimlane projection455555

A deliberate dissent from the expected framing: the obvious reading of "hierarchical-supervisor workflow model" is "build a supervisor runtime that spawns child executions." I score that reading as premature on this codebase. The system's genuinely scarce property is not durable concurrent execution (the job queue + fences already provide it) — it is an honest, entity-granular representation of that execution. Options (a)–(c) each buy runtime semantics Bloom largely has, at high cost, and still leave the representation to be built. Option (d) buys the representation at near-zero cost and leaves the runtime question to be decided with evidence. And when L2 is warranted, there is a fifth option hiding in the codebase's own architecture — see §5.


4. Recommendation

Adopt the hierarchical-supervisor model as: (d) now, native subworkflow later, behind an ADR gate. Phases:

Phase 0 — the shippable slice (design-only + read model; no runtime machinery)

  1. Author child specs as data (L1 from the start — declaring them costs the same as documenting them):
    • apps/server/workflows/bloom-ticket.workflow.json — nodes transcribing EngineRunState (§2.2), edges transcribing the documented transitions, terminals done / escalated.
    • bloom-pr-review.workflow.json — opened → specialist_round → verdict → merged / changes_requested⟲ / escalated (cap = review_max_rounds + minor-only extra rounds, orchestrator.py:2427-2429).
    • bloom-plan-change.workflow.json, bloom-decision.workflow.json, bloom-client-approval.workflow.json — the small pending→resolved gates. All validated by the existing load_spec at app startup, exactly like bloom-sdlc.
  2. Build the projection — a pure module (proposed services/project_flows.py) with one function per kind: (run.state, raw: _StatusSnapshot | None) → list[FlowInstance]. It reads only what §2.2 lists. Degrades exactly like #464: with raw is None, state-derived lanes still render and snapshot-derived enrichments drop out (mirroring test_missing_snapshot_degrades_to_state_signals_only, test_graph_status.py:107).
  3. Serve it — add flows to ProjectDetail (additive; orchestrator.project_detail, :4668). Frontend renders lanes under the existing SdlcGraph (the graph stays; it is the supervisor's view).
  4. Unify with #464 rather than duplicating it: reimplement _active_node_ids's four steady-state signals on top of the projection (open PR lane → review_pull_requests, live ticket lane → track_progress, plan/decision lanes → prd_authoring / request_prd_approval), keeping the existing tests green. One derivation, two renderings (supervisor node glow + lanes) — this removes the risk of the two views disagreeing.
  5. Fix the drift while we're here: declare pending_client_approvals in the spec's state fields and route its writes through apply_writes like every other field.

Phase 0 is deliberately mostly code-shaped but changes no behavior; per this worktree's constraints it is specified here and implemented in follow-up tickets.

Phase 1 — ADR gate for runtime machinery (design-only spike)

Write an ADR (docs/adr/) deciding whether any child flow needs to be executed rather than projected, with explicit criteria. Enter L2 only if at least one holds after Phase 0 has been in production:

  • G1 — inference is lying. The projection demonstrably mis-states child position (e.g. label truth vs engine_runs truth diverge in ways users see; cf. the stale eng: label class of bug, #457/#455) and reconciliation-by-reading cannot fix it.
  • G2 — control flow needs to move. A product requirement needs per-child suspend/resume that the orchestrator's imperative paths cannot express safely (e.g. pausing one ticket's rework loop while others proceed, owner-driven per-ticket gates).
  • G3 — the imperative loop is a defect factory. The review→rework→escalate code paths keep producing regressions of the #288/#457/#455 shape, arguing for a declared, testable child graph driving them.

Phase 2 — native child runs (runtime machinery, only if gated in)

Implement the already-reserved subworkflow node in the built-in engine (workflow/engine.py:164), not a new framework:

  • A child run is an ordinary RunState row keyed "{thread_id}:ticket:{n}" (etc.) in bloom_runs. Precedent exists: the chat project-index already rides in a RunState row with sentinel current_node="_index" (orchestrator.py:649-655) — the store handles non-project rows today.
  • The supervisor's steady-state loop spawns child runs at delegation time (_implement_delegated, orchestrator.py:3124) and the existing job/webhook handlers advance them; the child's processed_keys fence carries the same idempotency guarantees as the parent.
  • Start with one child kind — the ticket flow, because its enum is already the state machine — and run it in shadow mode: the child run advances alongside the untouched engine_runs writes, with a parity check between projection and execution (the same parity-testing pattern the LangGraph spike validated for the reconciler). Promote to authoritative only when parity holds in production.
  • LangGraph remains a candidate implementation of Phase 2 behind the same seam (BLOOM_COORDINATION_ENGINE demonstrates the pattern), but the default is the ~100-line engine we own: zero lock-in, already interprets the same spec dialect the child specs are written in, and the sketch's impedance mismatch (labels-as-truth) applies to it less — a child RunState is just another projection surface until shadow mode proves otherwise.

Explicitly design-only vs. machinery: Phase 0 = specs-as-data + read model (no machinery). Phase 1 = ADR (no machinery). Phase 2 = the first and only step that adds runtime machinery, and it is entered only through the gate.


5. Data model, API, and event schema

5.1 Read model types (Phase 0)

class FlowInstance(BaseModel):
id: str # "ticket:12" | "pr:34" | "plan:rev-5" | "decision:d7" | "approval:26"
kind: str # ticket | pr_review | plan_change | decision | client_approval | lifecycle
spec_id: str # "bloom-ticket" — the child workflow this instance instantiates
title: str # issue/PR title, proposal summary…
node: str # current node id IN THE CHILD SPEC
status: str # live | waiting_on_owner | done | escalated
attempt: int | None # engine attempt / review round
failure_kind: str | None # from EngineRun.failure_kind
links: dict[str, str] # issue/PR web URLs (reuse issue_web_url/pull_request_web_url)
updated_at: str | None # last_attempt_at etc.

class ProjectFlows(BaseModel):
supervisor: ProjectGraph # today's graph, unchanged
lanes: list[FlowInstance] # live + recently-terminal instances

ProjectDetail gains flows: ProjectFlows | None (additive — the web client's ProjectDetail interface in apps/web/src/hooks/use-project-detail.ts extends compatibly).

Presentation guidance: lanes are bounded. Live instances always render; terminal instances render for a bounded recency window and the rest collapse to counts — engine_runs is keyed by issue and never pruned, so an old project can hold hundreds of terminal entries.

5.2 Event schema

The event vocabulary is already per-entityticket_delegated {number, engineer}, engine_run_blocked {number}, engine_run_escalated {number, kind}, pr_reviewed {number, approved, merged}, ticket_escalated, ticket_unblocked, plan_changed, decision_proposed … (publishers throughout orchestrator.py; durable history in bloom_project_events, persistence/project_events.py). Two additive changes:

  1. Correlation convention: publishers add flow: "<kind>:<key>" to event data where the entity is known (mechanical: the number/title is already in hand at every publish site). The Timeline can then group by lane, and SSE-driven refetch (/{thread_id}/events, api/routes/projects.py:223) needs no change — the existing "notable event → refetch detail" contract already covers lane updates.
  2. No new event types in Phase 0. Lane lifecycle (flow_started/flow_completed) is derivable from existing events plus state; minting new write-path events before the ADR gate would couple the write path to a model we intend to keep read-only for now. (Phase 2 may add them when children become real runs.)

5.3 Persistence

  • Phase 0/1: none. No new tables, no migration of bloom_runs rows. The projection reads the state Supabase already holds; the event store is already durable (#450).
  • Phase 2: child RunState rows in bloom_runs under namespaced keys (§4). Queries that enumerate projects must exclude child rows — note _LIST_ACTIVE and find_thread_by_repo already filter on properties child rows won't carry, but this becomes an explicit invariant with a test, not an accident.

5.4 Failure/retry semantics for child flows

Stated as invariants (Phase 0 renders them; Phase 2 enforces them):

  • Retries live inside the child. Automatic retry of TRANSIENT/IMPLEMENTATION/UNKNOWN failures (retry-then-escalate, #455) is a self-loop on the child's implementing/rework nodes, bounded by EngineRunner policy; retries/failure_kind are child-instance fields.
  • Escalation is a child terminal plus a supervisor notification, never a supervisor stall. needs_attention (_escalate_engine_failureSTATUS_NEEDS_ATTENTION, unassign eng: label per #457, owner ping with concrete reason per #459) ends the child; other children are unaffected; the supervisor stays parked.
  • Review-round caps are child-spec data. review_max_rounds (+ minor-only extra, orchestrator.py:2427) bounds the pr_review child's rework cycle; the cap crossing is the edge to escalated.
  • Idempotency is per-child. The attempt-scoped event keys (engine_implement:{thread}:{issue}:{seq}) become, in Phase 2, the child run's processed_keys — same fence, narrower scope, and redelivery can no longer interleave across siblings through the shared parent row.
  • Blocked returns to the pool. blocked is a child terminal whose supervisor-side effect (unassign, status:blocked, PO re-route) is already implemented; a later re-delegation mints a new child instance (ticket:12#2), matching today's fresh event key per attempt-seq.

5.5 Backward compatibility

  • API: additive field; old clients ignore it.
  • Events: additive data.flow key; consumers that ignore unknown keys (the web app does) are unaffected.
  • State: one declared-field addition (pending_client_approvals) whose values already exist in production rows; declaring it changes validation, not data. New projects and old rows project identically because every projection input predates this design.

6. Test strategy

  1. Projection unit tests (Phase 0, the bulk): same style as test_graph_status.py — given an engine_runs entry / specialist_reviews round / pending proposal and an optional snapshot, assert the lane, node, attempt, and status. Include the degraded raw=None cases.
  2. Enum-exhaustive contract tests: iterate EngineRunState members and assert every one maps to a node that exists in bloom-ticket.workflow.json; ditto EngineOutcome, review verdicts, and FailureKind. A new enum member without a mapping fails CI — this is the mechanism that keeps specs-as-data honest.
  3. Spec validation tests: child specs load via load_spec (dangling-ref validation for free); supervisor invariant test: park node active ⇔ zero live lanes, extending test_graph_status.py's park-node cases.
  4. #464 parity: after _active_node_ids is re-derived from lanes (§4 step 4), the entire existing test_graph_status.py suite must pass unmodified. That suite is the regression contract for this refactor.
  5. Integration: extend test_coordination_flow.py-style scenarios — delegate two tickets, open a PR, propose a plan change; assert GET /api/projects/{id} returns three+ lanes with correct positions; assert timeline events carry flow correlation.
  6. E2E (web): seeded project renders lanes; SSE event triggers refetch and lane movement (reusing subscriber_count synchronization, services/events.py:81).
  7. Phase 2 shadow-mode parity (if gated in): child run's current_node vs projected node compared on every advance; divergence is a logged metric and a test failure in CI scenarios — the LangGraph-spike parity pattern (test_langgraph_coordinator.py) reused for a different engine.

7. Interaction with #464 (e67bd03)

This design treats #464 as its first increment, not something to route around:

  • Philosophical continuity. #464 chose derive from persisted truth over move the pointer. The lane projection is the same choice at entity granularity; Phase 2's gate exists precisely because moving truth into a runtime should require evidence the derivation can't keep up.
  • Mechanical unification. Until §4 step 4 lands, signals and lanes are computed twice from the same inputs — the one real drift risk this design introduces. Landing the re-derivation of _active_node_ids on top of the projection in the same release as the lanes is therefore part of Phase 0's definition of done, guarded by the untouched #464 test suite.
  • Snapshot economics. #464 threads the caller's already-fetched _StatusSnapshot into _build_graph to avoid a second provider round-trip (orchestrator.py:4705-4710); the projection takes the same parameter and the same "one GitHub round-trip per detail view" property holds (project_detail, :4674-4684).
  • Done-phase stability. _node_status's phase-index "done never regresses" rule (:5058) stays supervisor-side and untouched; lanes carry the churn.

8. Risks and open questions

Risks

  • R1 — two truths for tickets. engine_runs vs live labels can disagree (humans edit labels; webhooks race reports, cf. #288's status-echo guard). Mitigation: the ticket projection reads both and renders disagreement as a visible attention badge rather than silently preferring either — disagreement is information (it caught #457's stale-label class once already).
  • R2 — unbounded lanes. engine_runs/specialist_reviews grow monotonically. Mitigation: bounded rendering (§5.1); open question O3 for actual retention.
  • R3 — projection drift as code evolves. New orchestrator paths that mutate work-item state without updating the projection. Mitigation: the enum-exhaustive contract tests (§6.2) plus the reducer-layer discipline (undeclared-field writes already KeyError; closing the pending_client_approvals bypass removes the known hole).
  • R4 — Phase 2 dual-write divergence. Shadow children vs dict writes disagreeing under redelivery. Mitigation: shadow mode is read-compared only (never authoritative until parity), attempt-scoped fences already dedupe the inputs.
  • R5 — scope creep toward a framework. The swimlane ships and pressure builds to "finish" the runtime anyway. The ADR gate's criteria (G1–G3) exist to make that a decision with evidence, not momentum.

Open questions

  • O1 — Should plan_change/decision lanes fold into one "governance" lane in the UI? (Data model keeps them distinct; presentation may merge.)
  • O2 — Does the Telegram surface get a lane digest (the _send_status/digest paths already summarize some of this), or is lanes a dashboard-only concept initially?
  • O3 — Retention policy for terminal engine_runs/specialist_reviews entries — archive into the event store? Out of scope here but the lane view makes the growth visible.
  • O4 — In Phase 2, does the pr_review child own the merge action (today _apply_review_to_github merges inline), or does merging stay a supervisor-side effect? The client-approval park (_park_for_client) argues for supervisor-side: merges are gated by cross-child concerns (owner sign-off, credentials) a single child shouldn't own.
  • O5 — Multi-instance API deployment: the projection is stateless and safe, but Phase 2 child advancement must ride the existing job queue's atomic claim — confirm no new lock ordering between parent and child rows (child advance takes child lock only; parent effects go through the existing thread lock, in that order, never nested the other way).

Appendix A — code index (facts this design rests on)

FactWhere
Single pointer per projectworkflow/engine.py:29 (RunState.current_node)
Park node + descriptionworkflows/bloom-sdlc.workflow.json:359-366
Park state = "active project" querypersistence/supabase_store.py _LIST_ACTIVE; persistence/store.py list_active_threads
subworkflow reserved, unimplementedworkflows/schema/workflow.schema.json:355-365; workflow/engine.py:164-165
Ticket state machineengine/base.py:39-55 (EngineRunState), :163-213 (EngineRun, with_result)
engine_runs decl + writesbloom-sdlc.workflow.json:116; orchestrator.py:2924-2928
specialist_reviews decl + roundsbloom-sdlc.workflow.json:115; orchestrator.py:2252-2340
pending_plan_change write/clearorchestrator.py:3936 / :3962,3995
Undeclared pending_client_approvalsorchestrator.py:1363,1525 vs workflow/reducers.py:48-50
Engine runs outside the thread lock, fencedorchestrator.py:3156,3177-3219
Review rounds, caps, escalationorchestrator.py:2361-2438,2427; 2930-2969
#464 status derivationorchestrator.py:5016-5068; tests tests/unit/test_graph_status.py
Detail endpoint / one-snapshot ruleorchestrator.py:4668-4684; api/routes/projects.py:137,223
Frontend renders backend status verbatimapps/web/src/components/sdlc-graph.tsx; apps/web/src/hooks/use-project-detail.ts
Event bus + durable historyservices/events.py; persistence/project_events.py
Non-project rows in bloom_runs precedentorchestrator.py:649-655 (current_node="_index")
LangGraph spike + honest scorecardservices/langgraph_coordinator.py; docs/design/langgraph-sketch.md §7-8