Skip to content

ADR-0034: Domain-engine coordination and autonomy safeguards

  • Status: Accepted, amended 2026-10-24 and 2026-10-28 (see Amendments 1-3)
  • Kind: Retrospective
  • Area: orchestration
  • Date: 2026-07-09
  • Relations: supersedes v0-0075, v0-0077; extends ADR-0033

Amendment 1 (2026-10-15) — a judge that did not run as configured is raised

D3 has every judge exception other than budget exhaustion emit judge_error and return True. That is right when the judge ran and its provider or its parse failed. It is wrong when the engine's own settings kept the judge from running as configured: then no judge ran, and True reads a missing verdict as a pass, which turns the gate off for the whole run. Three cases are now raised instead:

  • The judge agent cannot be built with the engine's settings: today the engine's agent_env on a judge model that takes no environment (an API model), and an MCP server allowlist that cannot be enforced where an engine's make_agent is handed one (Engine.judge passes none itself). The build's error is raised as it is, with no judge_error. Any other error building the judge, an agent_mcp_config_path that does not parse among them, is not one of these: without containment it fails open as before, and under containment the third case raises it.
  • A ContainmentRefusedError is found on the error's chain. The model layer wraps a provider's refusal in its own error, and a wrapper that raises without from (or from None) leaves it as the context, so each link's cause and context are both followed, whatever object the link is, each link once, nearest first, and at most 100 links in all; the refusal is raised.
  • With contain_account_integrations set, any other error. judge_error is emitted first, then the error is raised. Under containment the refusal can arrive as an error of any type (a working directory the process cannot enter, a codex profile the request model rejects), and no error can show that a contained judge ran.

Budget exhaustion still returns False, and an engine without containment keeps D3's fail-open rule for every other error. The cost is the one D3's rejected alternative named: under containment a judge provider failure stops what the judge gates instead of letting it expand. Where an engine runs its stages under a guard that records a stage's error and carries on (the hypothesis engine), that is the item being judged, and the run goes on with its other items; a stage whose own failure releases its item onward runs the judge before that release, so a judge that raises ends the item. The item is recorded as that stage's error and the run returns what its other items produced: a stage failure makes the hypothesis engine raise only when nothing past its seeds was produced (a dedup verdict the engine acted on, a question, evidence or a conclusion), and every item its judge sees comes after the run has produced a question, so a judge that raises does not bring a run to that point. Nor can a dedup verdict carry a finding past its judge: the hypothesis engine treats a dedup verdict that names an agent-sourced finding (one of generation 1 or more) whose judge has not passed as one naming no finding, whichever agent emitted it. Nothing acts on it, it does not answer that finding's search, and the filing queue leaves it out. Typed verdicts also require their actual emitting branch: only the judge created for the item can decide it, and only the finding's own search agent can answer its novelty check. Run-local, post-gate emission receipts distinguish concurrent agents and replayed payload occurrences; text fields and role labels do not establish origin. Reports and filing use each verdict occurrence's accepted state. Only an explicit caller emission can introduce a parentless seed; a parentless agent emission cannot skip the judge by claiming generation 0. The policy gap tracked in issue #3569 let a judge reply with no verdict pass unless its text contained "reject". Amendment 2 closes that gap. An engine that needs the judge to be optional leaves judge_model unset.

Amendment 2 (2026-10-24) — explicit untyped judge decisions

  • Status: Accepted 2026-10-24; replaces D3's untyped text fallback.
  • Scope: Partial amendment of D3's untyped judge-response rule and the text-policy gap recorded in Amendment 1. The accepted base and the other Amendment 1 decisions remain standing.

When the item's actual judge emits no authoritative matching typed verdict, the accepted fallback returns True only when isinstance(res, str) and res.strip().lower() == "pass". Whole-reply PASS, pass and Pass tokens allow, including surrounding whitespace. A bare REJECT, punctuated or Markdown tokens such as PASS. or **PASS**, empty or missing replies, explanatory prose, mixed tokens, and non-string values return False. Non-string replies are not converted to text to obtain a verdict.

An untyped refusal uses the existing gated notification with the requested eid and reason="text-reject"; the notification does not copy the raw reply. An authoritative typed verdict is checked first, so its allow/deny value and reason continue to decide even when the reply text disagrees.

A successfully returned off-contract reply is a negative gate result, not an exception: it must not enter the infrastructure-error fail-open path. This amendment leaves disabled judging, budget exhaustion, provider/build failures, containment refusals, emitter authority and caller-seed admission unchanged. Ordinary infrastructure failures still follow D3 and Amendment 1. If an event callback raises on the gated notice, a typed or untyped refusal enters that fail-open path and the item expands unless contain_account_integrations is set; this existing behavior is tracked in #3744.

The compatibility cost is intentional: weak judges that returned prose or no verdict previously allowed expansion; they now stop that item. Callers can use a matching typed verdict, return the explicit text token, or leave judge_model unset when judging is optional.

Amendment 3 (2026-10-28) — preserve a reported gate decision

  • Status: Accepted 2026-10-28; replaces Amendment 2's statement that a callback failure on the gated notice enters the fail-open path (#3744).
  • Scope: A callback failure while reporting a completed gated decision. Amendment 2's text is kept as it was accepted; where it describes that failure, this amendment decides.

An ordinary gated callback exception cannot turn a typed or untyped denial into an expansion. The engine logs its exception type with a redacted message, without a traceback, and returns the denial. A ContainmentRefusedError or _AgentSettingError, whether direct or found through an exception's cause, context, or exception-group members, is raised instead. The search is bounded to 100 distinct nodes and 100 group-member entries; if it cannot finish or a link is unreadable, the original callback exception is raised rather than treating an unread chain as ordinary. Interrupts and cancellations continue to propagate. This rule does not change how failures in the judge itself are handled, or promise delivery of a failed notification.

Amendment 4 (2026-10-31) — request and policy refusals in the judge

  • Status: Proposed; pending acceptance.
  • Scope: Judge failures under D3 and Amendment 1. Amendment 3's handling of notification failures after a denial is unchanged.

P5 lets the judge fail open on infrastructure failures. An input the judge cannot send or a policy refusal that prevents its launch is different: retrying the same request without changing it cannot produce a verdict, so treating that refusal as a pass can bypass the gate for the whole run. These are request or policy refusals, not infrastructure failures under P5.

In addition to containment and exclusive MCP selection refusals, the judge raises CLIInputRefusedError, InputTokenCapError, NestedAgentRefusedError, and _AgentSettingError when reached directly, through cause or context, or in an exception group. A directly raised _AgentSettingError keeps its existing handler before the search. A containment refusal inside a CLI input refusal retains precedence over the outer CLI refusal; among the four added classes, the first found in walk order is raised.

The search remains breadth-first, nearest first: each object's context enters the queue before its cause, and both enter before exception-group members, which enter in member order. One budget of 100 visited slots covers all links and members, including duplicate slots; a seen object is skipped to break cycles, but its slot still counts. Group members are read lazily up to the remaining budget, never copied wholesale. Unreadable links are skipped. Beyond the bound, the existing uncontained outcome remains judge_error and allow.

A direct EngineBudgetError still returns False. Without account containment, generic ConfigurationError still fails open because it includes missing credentials and provider setup; provider 4xx/5xx, transient admission failures and generic runtime failures likewise emit judge_error and allow. Under contain_account_integrations, other errors are reported and raised as in Amendment 1. Cancellation propagates.

This rule applies to types that reach the judge. The CLI argv preparation boundary can replace an _AgentSettingError, InputTokenCapError, or NestedAgentRefusedError with a sanitized RuntimeError without its original cause; that path does not preserve the type and is not changed by this amendment.

Context

Domain engines encode reusable reaction topology for work whose decomposition is stable enough to deserve code. Six problems shape that layer.

P1 — Reusable policy and mutable run state have different lifetimes. Model routing, roles, thresholds, and caps may be reused. A Session, observed events, deduplication keys, pending coroutines, counters, and a deadline must not leak from one invocation into the next. lionagi/engines/engine.py separates Engine from EngineRun for that reason.

P2 — The shipped domains do not share one useful topology. Research is a novelty-bounded tree, review is dimensional fan-out plus verification and verdict, hypothesis is an event-reference chain with pending experiments, coding is a subprocess-gated plan/implement/test/fix/verify chain, and planning is a planned operation DAG. Forcing all five through one topology would move domain semantics into generic graph plumbing.

P3 — Prompted autonomy limits are not safety controls. Recursive or reactive work must stop at hard agent and wall-clock bounds, and concurrent activity must pass a semaphore. Semantic depth, deduplication, and a quality judge reduce work; they cannot replace the hard bounds.

P4 — Typed emissions can be absent or malformed. Domain transitions depend on events such as findings, questions, tests, and verdicts. A prose-only or invalid response must be observable and may receive a bounded repair turn, but the engine must not fabricate the missing domain event.

P5 — Expansion quality and terminal reporting have different failure policy. A judge is advisory: infrastructure failure fails open while budget exhaustion fails closed. Terminal synthesis or partial export is budget-exempt so evidence already collected is not lost merely because discretionary expansion stopped.

P6 — Bounded completion must remain distinguishable from a broken run. String results retain str compatibility while carrying a live run handle, skipped emission diagnostics, and explicit degradation state. Internal deadline or agent-budget exhaustion can return partial evidence; external cancellation and non-budget failures still propagate.

Concern Decision
Configuration and state lifetime D1: Engine is reusable configuration and EngineRun owns all invocation state.
Hard resource bounds D2: agent creation and wall-clock deadlines dominate semantic gates; concurrency is separately bounded.
Typed event coordination D3: event queries unwrap payloads, repair is bounded, and judge failures follow explicit policies.
Domain topology D4: each concrete engine owns its reaction shape and typed events.
Terminal and degraded result D5: string results use EngineResult; budget/deadline paths use bounded partial export and explicit reasons.
Operation-DAG interoperation D6: engine DAG execution observes and delegates through ADR-0033 instead of scheduling again.

Out of scope:

  • Generic graph scheduling and reactive node admission. ADR-0033 owns them.
  • Role-palette composition and dynamic role recruitment. ADR-0036 owns that target.
  • Resident queue leases, task attempts, and process recovery. ADR-0037 owns the host boundary.
  • Provider implementation and tool execution semantics. Engines select existing Branch configuration; they do not redefine providers or tools.
  • A universal durable event-retention policy. The current store is in memory; the missing compaction contract is recorded in the delta.
flowchart TD
    E[Reusable Engine config] --> R[Fresh EngineRun]
    R --> S[Session and observer]
    R --> B[Budget and deadline]
    R --> C[Concurrency semaphore]
    R --> Q[Active and pending tasks]
    S --> V[Typed domain events]
    V --> T[Domain reaction topology]
    B --> T
    C --> T
    T --> X[Terminal or partial export]
    X --> O[EngineResult or structured domain result]

Decision

D1 — Reusable Engine, isolated EngineRun

Engine stores stateless configuration. new_run() creates all mutable invocation state, using a caller-provided Session only when explicitly supplied.

The contracts (lionagi/engines/engine.py):

class Engine:
    run_context_cls: type[EngineRun] = EngineRun

    def __init__(
        self,
        *,
        model: str | None = None,
        models: dict[str, str] | None = None,
        max_depth: int = 3,
        max_concurrent: int = 5,
        max_agents: int = 50,
        deadline_s: float | None = None,
        judge_model: str | None = None,
        judge_role: str = "critic",
        cancel_timeout_s: float = 30.0,
    ) -> None: ...

    def new_run(
        self,
        *,
        session: Session | None = None,
        on_event: EventCallback | None = None,
        on_branch_created: Callable[[Any], None] | None = None,
    ) -> EngineRun: ...

class EngineRun:
    def __init__(
        self,
        engine: Engine,
        *,
        session: Session | None = None,
        on_event: EventCallback | None = None,
        on_branch_created: Callable[[Any], None] | None = None,
    ) -> None: ...

The fresh run owns:

session: Session
root: str
agents_made: int
_sem: Semaphore(engine.max_concurrent)
_active: set[asyncio.Task]
_pending: deque[coroutine]
_seen: set[str]
_t0: monotonic timestamp
_deadline: monotonic timestamp | None
_run_task: asyncio.Task | None
_emission_failures: list[str]
_agent_errors: list[str]

Exact semantics:

  • No Session supplied: the run constructs a new Session().
  • Session supplied: the run uses that Session and its observer; it does not clone or reset it.
  • models is copied on Engine construction. model_for(stage) returns the stage-specific mapping when truthy, otherwise the base model.
  • seen(key) strips and lowercases the key. It returns True for a duplicate and records a previously unseen key.
  • events is the Session observer flow's item pile. Long-lived runs retain that in-memory history; no compaction occurs.
  • on_event, when set, receives plain dictionaries of the form {"type": kind, **data}. Agent errors are additionally accumulated.
  • ChainRun adds per-event-type lists, deterministic prefix counters, an eid -> event index, and pending-domain state in its concrete subclasses.

Why this way: the Engine object is safe to configure once and reuse. Mutable state has a single owner and lifetime, which makes budget, quiescence, and cleanup reasoning local to one invocation.

D2 — Hard agent/deadline bounds and separate concurrency

Agent count and deadline are hard run controls. Concurrency limits simultaneous work but does not grant more budget.

The creation contract (lionagi/engines/engine.py):

async def EngineRun.make_agent(
    self,
    role: str,
    *,
    name: str | None = None,
    modes: list[str] | None = None,
    model: str | None = None,
    tools: tuple[str, ...] = (),
    emits: tuple[type, ...] = (),
    permissions: Any = None,
    cwd: str | None = None,
    secure: bool = True,
    exempt: bool = False,
    mcp_servers: list[str] | None = None,
    extra_prompt: str | None = None,
) -> Branch: ...

class EngineBudgetError(RuntimeError): ...

Exact semantics:

  • budget_left() is false when agents_made >= max_agents or the monotonic deadline has arrived. Depth, novelty, deduplication, and judge results are not consulted by this hard predicate.
  • A non-exempt make_agent() call without budget emits one budget_exhausted notification for the run and raises EngineBudgetError.
  • An exempt terminal agent bypasses the pre-check but still increments agents_made. Exemption preserves reporting; it is not hidden from accounting.
  • spawn(coro) checks the same predicate before scheduling. On exhaustion it closes the coroutine when possible, emits the one-time notification, and returns None.
  • A spawn attempted without a running asyncio loop is queued in _pending; drain_pending() later schedules every queued coroutine.
  • wait_quiescence() repeatedly gathers active tasks. CancelledError and EngineBudgetError are treated as bounded expansion stopping; any other task error is re-raised, singly or as an exception group.
  • The deadline watchdog sleeps until _deadline, records exhaustion, and cancels the root _run_task. Engine.run() then cancels spawned tasks in cleanup.
  • cancel_active() requests cancellation, waits at most cancel_timeout_s, warns about tasks still pending, issues a final cancel, and returns. A task that suppresses cancellation can therefore outlive the wait window, but the run does not wait indefinitely.
  • run_team() is sequential even though the run owns a semaphore. One agent failure is converted to a notification and failure text so later team members continue.

Shipped numeric defaults and their traceable rationale:

Setting Default Recorded reason
Engine.max_depth 3 Inherited compatibility default; no rationale is recorded in source.
Engine.max_concurrent 5 Inherited compatibility default; no rationale is recorded in source.
Engine.max_agents 50 Inherited compatibility default; no rationale is recorded in source.
Engine.deadline_s None No wall-clock deadline unless the caller chooses one.
Engine.cancel_timeout_s 30.0 seconds Bounds cleanup of cancellation-resistant tasks; the exact value has no recorded rationale.
Partial-export timeout 120.0 seconds Kept short enough that a hung synthesis call cannot extend a cancelled run without bound; the exact value has no further recorded rationale.

Why this way: semantic controls improve work selection but are fallible model or domain policy. Agent creation and monotonic time are enforceable locally and remain authoritative when those policies fail.

D3 — Typed payload queries, bounded repair, and advisory judging

Domain coordination observes typed emissions on the Session bus. EngineRun provides a payload-oriented query so callers do not depend on envelope shape.

The contracts (lionagi/engines/engine.py):

class EngineEvent(BaseModel):
    model_config = ConfigDict(extra="forbid")

class JudgeVerdict(EngineEvent):
    subject: str = ""
    allow: bool = True
    reason: str = ""

def EngineRun.by_type(self, event_type: type) -> list[Any]: ...

async def EngineRun.operate_with_repair(
    self,
    branch: Branch,
    instruction: str,
    *,
    arrived: Callable[[], bool],
    emits: tuple[type, ...] = (),
    retries: int = 1,
) -> Any: ...

async def Engine.judge(
    self,
    run: EngineRun,
    eid: str,
    subject: str,
) -> bool: ...

Exact semantics:

  • by_type(T) walks observer items, unwraps Signal.data, and delegates matching to the observer's type/callable filter. Matching values inside structured capability bundles are returned as payloads, not their envelopes.
  • operate_with_repair() always performs the original operation once. While arrived() remains false, it re-prompts at most retries times.
  • CLI-backed branches receive a complete fenced-JSON example built from required Pydantic fields. Other branches receive expected top-level emission keys and a schema-correction instruction.
  • Repair asks the model to emit; it never constructs a domain event on the model's behalf.
  • If all attempts finish without arrived(), the method emits emission_missing, records <agent> x<attempt-count>, and returns the last raw response.
  • All concrete engine repair defaults are one retry. No comparative rationale for one rather than zero or two is recorded in source; it is a bounded compatibility default.
  • No judge_model: judging is a no-op and returns True.
  • A typed JudgeVerdict whose subject matches the requested id is authoritative only when dispatched after the governance gate by that item's actual judge branch in this run. Session emissions and another branch's verdict are evidence, but do not decide the gate. Amendment 2 replaced the original text fallback: "In its absence, response text containing reject fails the gate; other text passes." The accepted fallback requires an untyped string reply whose stripped, lowercased value is exactly pass.
  • Judge EngineBudgetError returns False: no budget means no expansion.
  • Other judge exceptions emit judge_error and return True: infrastructure failure is fail-open while D2 remains authoritative. Amendment 1 raises three cases instead.

Why this way: payload-oriented queries keep engines independent of observer transport. Bounded repair addresses common serialization failure without turning a missing emission into invented evidence. The judge is an optimization, not a hard availability dependency.

D4 — Each engine owns its domain reaction topology

The concrete engines intentionally do not share one execution shape.

Shipped topology and public configuration:

Engine Constructor additions Reaction shape and edge behavior
ResearchEngine (lionagi/engines/research.py) novelty_threshold=0.7, roles ("researcher", "analyst", "critic"), synthesis_role="synthesizer", repair_retries=1 Runs each node's team sequentially; high-novelty findings and explicit depth requests spawn deeper nodes until max_depth; normalized topic dedup prevents re-exploration.
ReviewEngine (lionagi/engines/review.py) four default dimensions, reviewer/verifier roles, verify_severities=("critical", "major"), repair_retries=1 Reviews dimensions concurrently; qualifying issues spawn adversarial verification; all verifiers quiesce before one exempt verdict turn.
HypothesisEngine (lionagi/engines/hypothesis.py) seven stage roles, executable methods ("analysis", "comparison", "proof"), max_questions=8, repair_retries=1 Typed events carry reference ids through question, evidence, hypothesis, experiment, result, conclusion, and application reactions; non-executable experiments enter run.pending.
CodingEngine (lionagi/engines/coding.py) plan/implement/verify roles, max_fix_rounds=3, test and turn timeouts 600.0, heartbeat 30.0, optional stage timeout, guarded worker tool settings Runs a plan/implement/test/fix/verify chain; subprocess results are ground truth; missing change metadata can be synthesized only when workspace delta proves work occurred.
PlanningEngine (lionagi/engines/planning.py) orchestrator, five-role default roster, synthesis role, reactive=True Plans TaskAssignment objects, retries an empty plan once, builds an operation DAG, delegates through D6, then synthesizes results.

The exact domain event fields remain in their defining modules. The load-bearing base shapes include:

class ChainEvent(BaseModel):
    eid: str = ""  # engine-assigned

class FindingEmitted(Finding):
    novelty: float = Field(default=0.5, ge=0.0, le=1.0)
    depth: int = 0

class IssueFound(Finding):
    dimension: str
    location: str = ""
    severity: str = "minor"

class VerifyResult(EngineEvent):
    issue: str
    holds: bool = True
    rationale: str = ""

Exact cross-engine semantics:

  • Research rejects an empty stripped topic. Hypothesis rejects an empty normalized seed list. Planning rejects an empty stripped prompt and raises PlanError after one empty-plan retry. Coding validates its spec before run state and requires a non-empty test command.
  • Research and hypothesis override _partial_export() only when evidence exists; they prefix a budget-exhausted status and synthesize the retained events.
  • Review's concurrent dimension failure cancels already-spawned verifier work and propagates. Individual research team-agent errors are recorded and the team continues.
  • Hypothesis stage guards catch and notify ordinary stage errors so unrelated event chains can continue. Non-executable experiment methods are retained as pending rather than treated as failure.
  • Coding's test timeout is 600 seconds, turn timeout is 600 seconds, maximum fix rounds is three, and heartbeat interval is 30 seconds by default. The source explains the behavioral role of each bound but records no rationale for the exact values.
  • Research's 0.7 novelty threshold, hypothesis's eight-question cap, and coding's three-fix cap likewise have no recorded numeric rationale. They are explicit policy defaults and caller-overridable.
  • Only PlanningEngine submits a Graph[Operation] through Session.flow(). The others coordinate Branch turns and typed domain events directly.

Why this way: the common layer is lifecycle, budget, event observation, repair, and terminal behavior. Reaction edges encode domain meaning and stay with the engine that can name and test that meaning.

D5 — Explicit degradation and backward-compatible string results

String-producing engines return an EngineResult, a str subclass with structured run metadata. Non-string domain results pass through unchanged.

The contracts (lionagi/engines/engine.py):

class EngineResult(str):
    text: str
    skipped: list[str]
    degraded: bool
    degrade_reason: str
    run: EngineRun

    def __new__(
        cls,
        text: str,
        *,
        events_by_type: Callable[[type], list[Any]],
        skipped: list[str],
        degraded: bool,
        run: EngineRun,
        degrade_reason: str = "",
    ) -> EngineResult: ...

    def events_by_type(self, event_type: type) -> list[Any]: ...

async def Engine.run(
    self,
    *args: Any,
    session: Session | None = None,
    on_event: EventCallback | None = None,
    on_branch_created: Callable[[Any], None] | None = None,
    **kwargs: Any,
) -> Any: ...

Exact semantics:

  • str(result) == result.text. skipped is a copy of missing-emission diagnostics. events_by_type() remains backed by the live run's query method.
  • A clean string result has degraded=False and degrade_reason="".
  • If discretionary spawned work hit the budget but the root completed, degrade_reason="budget" is still set; truncation is not hidden by the success path.
  • A root-level EngineBudgetError, or an exception group containing only budget errors, cancels active work, runs _partial_export(), and marks budget.
  • An internal deadline cancellation follows the same export path and marks deadline.
  • Partial export is shielded and bounded to 120 seconds. Timeout or export failure is logged and yields no partial result; external cancellation during partial export cancels the export task and propagates.
  • An exception group with any non-budget leaf propagates. Budget handling does not launder ordinary failures into a degraded result.
  • Caller cancellation propagates after run-owned cleanup. On Python 3.10, a caller cancellation simultaneous with the deadline may be classified as the internal deadline because the stronger Task.cancelling() signal is unavailable; the source records this compatibility edge case.
  • _wrap_result() wraps only plain strings. Existing EngineResult and non-string values pass through. CodingEngine therefore returns its structured CodeResultRecorded rather than an EngineResult.
  • The run property retains the whole Session and its branches. Consumers should not keep it longer than needed.

Why this way: callers that historically consumed a string keep working, while new callers can distinguish full success, bounded partial completion, and missing emissions without parsing report prose.

D6 — Engine DAG execution delegates to the graph boundary

An engine may use a graph when its domain topology is naturally an operation DAG. It does not gain a second scheduler.

The contract (lionagi/engines/engine.py):

async def EngineRun.run_dag(
    self,
    graph: Any,
    *,
    reactive: bool = False,
    spawn_type: type | None = None,
    node_builder: Any = None,
    max_spawn: int = 50,
    max_concurrent: int = 5,
    verbose: bool = False,
    executor_ref: dict[str, Any] | None = None,
    context: dict[str, Any] | None = None,
    spawn_branch_setup: Any = None,
) -> dict[str, Any]: ...

Exact semantics:

  • The method enters flow_progress_signals(self.session, graph), passes the resulting callback to Session.flow(), awaits the result, then exits the context so observer emissions drain before return.
  • It applies no planning, role, budget, or domain policy itself. PlanningEngine chooses the graph before calling it.
  • max_spawn=50 and max_concurrent=5 mirror the ADR-0033 defaults. No separate engine rationale is recorded.
  • The method's generic placement on every EngineRun is current package debt. The neutral observed-flow facade in ADR-0033's delta is the intended generic owner.

Consequences

  • Known workflows reuse tested domain policy without replanning every invocation.
  • Resource bounds, typed-emission repair, judge behavior, cleanup, and terminal degradation are consistent across local, API, CLI-backed, and process-gated workers.
  • New engines add real maintenance surface: event models, reaction edges, routing stages, partial-export semantics, and conformance tests.
  • Advisory judging and repair add bounded model cost. Exempt terminal turns can take the recorded agent count beyond max_agents by design.
  • The live EngineResult.run handle and retained observer history can consume substantial memory in long runs. Callers must release results they no longer need; a compaction contract is not yet shipped.
  • Contributors must choose explicitly between a domain reaction loop and an operation DAG. That distinction is intentional and affects where failures, retries, and typed events belong.
  • Reversing D1 or D5 is high cost because it changes reuse and caller-visible result semantics. Changing a concrete topology is scoped to that engine but can invalidate its event-reference and partial-export contracts.

Current-vs-ideal delta

# Delta Size Issue
1 Define and implement an event-retention or compaction policy for long-running EngineRun instances, with a test proving that required audit and terminal-summary events survive compaction. M (filled at issue-open time)
2 Change EngineRun.run_dag() into a compatibility delegator over the neutral observed-flow facade and document the facade, rather than the engine method, as the generic graph-observation surface. S (filled at issue-open time)
3 Add one conformance suite that exercises agent budget, deadline cancellation, emission repair, judge failure, and terminal degradation across every concrete engine shape. M (filled at issue-open time)

Alternatives considered

One DAG-only engine model

This would give every workflow one visualizable scheduling representation and reuse ADR-0033 universally. It lost because research and hypothesis react to typed events whose future branches do not exist at plan time, while coding contains subprocess gates and workspace truth that are clearer as a guarded chain. Encoding all of that as generic graph metadata would hide domain policy in node callbacks.

One bespoke runtime per domain

This would let each engine choose its own task lifecycle and optimize aggressively. It lost because agent budget, deadline cancellation, event observation, repair, and cleanup are cross-domain invariants. Copying them would create inconsistent failure and degradation behavior.

Store mutable state on the reusable Engine

This would reduce the number of objects and make counters easy to inspect. It lost because concurrent or sequential reuse would share Sessions, counters, pending tasks, and dedup keys. A prior run could suppress or exhaust later work.

Prompt-only autonomy limits

This would avoid counters, watchdogs, and cancellation machinery. It lost because a model can ignore or misunderstand a prompt, and recursive reactions can be emitted indirectly. Enforceable agent creation and monotonic deadline checks are required.

Make the quality judge authoritative and fail-closed on every error

This would prevent expansion whenever quality cannot be proven. It lost because a judge provider or parse failure would become a global availability dependency. Budget exhaustion still fails closed; other judge errors fail open under the hard bounds. Amendment 1 raises every other judge error under contain_account_integrations.

Fabricate a minimal event after repair exhaustion

This would keep pipelines moving and simplify downstream code. It lost because a syntactically valid placeholder is not evidence. Missing emission stays observable, and downstream domain logic decides whether absence is legitimate or terminal.

Raise on all budget exhaustion

This would make limits unmistakable. It lost because a bounded run may already hold useful evidence, and losing terminal synthesis makes the budget less useful. EngineBudgetError remains the internal control signal; the public string result marks degradation explicitly.

Return a new result model instead of a str subclass

This would be cleaner for new code. It lost on backward compatibility: existing callers expect text. EngineResult preserves string behavior while adding a typed inspection surface.

Unbounded repair until a typed event arrives

This would maximize the chance of satisfying a stage contract. It lost because a malformed or incapable worker could spend the entire run. One retry is the shipped default; final absence is recorded rather than hidden.

Put graph scheduling inside PlanningEngine

This would make the planning engine self-contained. It lost because graph execution, branch allocation, lifecycle, and live admission already have one owner in ADR-0033. run_dag() is an observation-and-delegation convenience only.