Condor Mutation, Replan and Observers — Two Unrelated Gates Sharing One Word
Audiences: developer, internal
READ THIS FIRST: "mutation" means two unrelated things in Condor
Condor uses the word mutation for two subsystems that have nothing to do with each other. They live in different folders, address different things, and neither can substitute for the other. A reader who conflates them will go looking for approval logic in the error-recovery subsystem and find none.
| Plan-graph mutation | Data mutation | |
|---|---|---|
| What it changes | the compiled plan graph, mid-run | a clinical record in the HMS |
| Why it fires | a step failed / needs recovery | a tool wants to CREATE / UPDATE / DELETE |
| Authority file | mastra/orchestrator/mutation-gate.ts | tool-platform/tool-policy.ts |
| Its own words | "WS7 bounded mutation gate: typed decisions over typed evidence" (mutation-gate.ts:2) | "the SINGLE approval authority for the tool layer: the verb × domain × env matrix" (tool-policy.ts:4-6) |
| Address space | CompiledGraphLedger stepId coordinates | tool descriptor (policyClass × hitlTier) |
| Human involvement | none, normally — it is automatic error recovery | this is exactly what HITL approval gates |
| Touches FHIR/Prisma | never | that is its entire purpose |
| Documented in | this topic | condor-hitl-approval |
They are parallel, not layered. A step can fail and trigger a plan-graph
retry-with-different-tool with zero human involvement. A step can be a
mutate-class tool call that suspends for approval with the plan-graph
mutation gate never consulted. Neither implies the other.
They join at exactly one line. compiler/leaf-builders/tool-call.ts:52
imports isPolicyClass from tool-platform/tool-policy. That import is the
whole intersection — the leaf builder that constructs a tool-call step is the
only place where the plan-compilation side of the house reads the data-mutation
policy engine.
Everything below is Axis 1 plus the observer layer, except where it explicitly points across to Axis 2.
Axis 1, in order: what happens when a step fails
1. Outcome classification
Every leaf's Mastra step result is classified in the pump's drain loop
(pump/phases/dispatch-phase.ts) into a typed status —
succeeded / failed / skipped / degraded. The classifier lives at
ddx-api/src/ai/condor/step-outcome.types.ts (note: repo root of condor/,
not under mastra/orchestrator/).
2. The WS7 decision — evaluateMutationDecision
Called at pump/phases/dispatch-phase.ts:891. It is pure and reads only
typed MutationEvidence — no conversation text, no raw entity ids. The
decision vocabulary is a closed set in mutation-decision.types.ts:30
(MUTATION_DECISION_KINDS):
- terminal leaf →
continue - failed non-terminal + supervised →
request-approval— a plan mutation under supervision suspends through the approval gate before any re-scope - failed non-terminal + autonomous →
re-scope-tools(bounded) - skipped / suspended non-terminal →
skip— prune the dependent-free tail - degraded or succeeded →
continue— a degraded leaf is reported honestly, not treated as a failure trigger stop— emitted by the circuit breaker (below)
replan-tail is deliberately excluded from the vocabulary and guarded by
FORBIDDEN_MUTATION_DECISION_KINDS (mutation-decision.types.ts:62).
3. CircuitBreakerLedger — the run-level budget envelope
Also in mutation-gate.ts. Wraps all of the above with run-level ceilings:
max mutations per run, max retries per step, identical-input and
identical-decision caps, wall-time, token and cost ceilings, and
maxReplansPerRun (DEFAULT_MAX_REPLANS_PER_RUN = 2, mutation-gate.ts:103)
— the replan-storm breaker.
Every trip is a typed {kind:'stop', outcome:'failed'|'degraded'}. Never a
silent infinite loop.
This is an outer envelope, not a replacement: it does not bypass or widen
the pre-existing per-attempt ceilings inside tool-call.ts
(MAX_TOOL_RETRY, MUTATING_INPUT_REGEN_ATTEMPTS,
RENDER_UI_BLOCK_REGEN_ATTEMPTS).
4. runMutationGate — the Phase-1 pump composer
workflow-runner.mutation-gate.ts:95, called after the WS7 decision in the
same drain loop. WS7 sits on top of this older gate; it does not replace
it. Five documented invariants:
- pure decision via
mutation-gate.plan.ts::evaluateMutationGate - separate-model confirm —
confirmMutationWithSeparateModel(mutation-gate.evaluator.ts) when the fired rule is ambiguous - apply the verb within the already-compiled graph —
no recompile mid-run — via
skip-remainingorretry-with-different-tool(requeryToolsForOutcome, re-querying the same alias/RBAC path) - emit every applied decision via
emitPlanRevised(agui-event-store.service.ts) as aPlanRevisedEventwire frame - bound the loop with
MutationBudget
5. MutationEngine — the substitution authority
mutation-engine.ts:122. Ledger-coordinate addressed. Exactly one verb is
shipped today: retry-with-different-tool, the schema-safe tool-id
substitution already living in workflow-runner.plan-patch.ts::patchPlanToolIds
— the engine re-addresses it by CompiledGraphLedger coordinates rather than
raw plan re-injection. skip-remaining / halt are named for contract
completeness; their handlers live in the runner/policy layer.
A designed-but-dormant seam: SpliceVerb
(insert-step / replace-subplan, mutation-engine.ts:92) is a first-class
documented contract, but applySplice() deliberately throws
MutationSeamNotImplementedError (mutation-engine.ts:98). It is deferred to
the deep-agent roadmap — do not plan against it as if it works.
Replan storms: what triggers one, and what bounds it
A replan storm is an adversarial or buggy step that keeps signalling "needs replan" — left unbounded it re-enters the gate forever, burning tokens and wall-time with no terminal state.
The adversarial step is an explicit design input, not a hypothetical. The
spec header states the requirement directly (replan-storm.spec.ts:2-5):
"WS5 (task 005, AC5.4 / FM1): the re-plan STORM breaker. An always-replan (buggy/adversarial) step must trip the
maxReplansPerRuncap and stop with a TYPED{kind:'stop'}— degraded when any leaf succeeded, never an infinite replan loop."
So the documentable fact is not "replans are bounded" but: replans are capped
at 2 by a registered circuit breaker, because a step that always requests a
replan would otherwise never terminate. Termination is the asserted property
— describe('re-plan storm terminates (AC5.4 / FM1)').
The bound is maxReplansPerRun inside CircuitBreakerLedger, and the
contract is falsifiably encoded in
mastra/orchestrator/replan-storm.spec.ts. Read that spec as the spec — it
asserts:
| Assertion | Meaning |
|---|---|
default is 2 (AC5.1) | DEFAULT_MAX_REPLANS_PER_RUN — and DEFAULT_MUTATION_BUDGETS.maxReplansPerRun asserted equal to it, so the constant and the shipped budget cannot drift apart |
'max-replans' is in BREAKER_NAMES (mutation-decision.types.ts:280) | it is a registered, named breaker participating in the breaker registry — not an ad-hoc counter |
| always-replan step trips at exactly the cap | not before, not after, never infinite |
trips degraded when any leaf succeeded | partial work is delivered honestly, not discarded |
trips failed when no leaf succeeded | nothing to deliver |
| one replan does not trip | the cap allows real recovery |
a 0 or negative cap clamps up to the default floor | the breaker can never be configured off |
recordReplan also counts toward the mutation envelope | replans are not a budget loophole |
That last two rows are the important ones for anyone tuning config: you cannot disable the replan breaker, and replans are not free with respect to the wider mutation budget.
Three different "fix a bad plan" mechanisms — do not merge them
These look alike and are constantly confused. They fire at different lifecycle points and solve different problems.
| Mechanism | File | Lifecycle point | What it does |
|---|---|---|---|
| One-shot LLM repair | plan-repair.service.ts::repairOnce | plan candidate is parse/schema-invalid or compile-invalid, before execution | invokes plan generation exactly once with a sanitized, issue-classified payload; a failed repair returns {ok:false} and lands in the plan-compile-failed terminal branch. No retry-until-valid loop. |
| Publish-once bookkeeping | plan-revision.ts | plan is already published, then structurally edited mid-run | emits a PlanRevision{planId, revision, baseRevision, operations, planHash} instead of a second indistinguishable data-workflow-plan frame, so a consumer reconciles against the base rather than seeing two unlabelled plans. planHash() delegates to shapeHash for emit/execute/persist parity. Not a repair mechanism. |
| Replan-storm breaker | mutation-gate.ts CircuitBreakerLedger | mid-run, across many steps | stops unbounded replan loops. Not a repair mechanism either. |
The observer / processor layer
⚠ kernel-registry.ts is NOT the runtime observer registry
processors/kernel-registry.ts sits beside post-tool-processor.ts and shares
the "registry" idiom, but it is admin Builder-UI display metadata. Its own
header: "Processor kernel display-metadata registry. Mirrors the shape of
TOOL_DISPLAY_REGISTRY so the builder UI can render kernel-aware processor
config forms." It is served to the admin Builder so it can draw config forms.
Its eight entries correspond to processor classes wired into
buildInputProcessors / buildOutputProcessors plus BatchPartsProcessor —
but this file is the catalog, not the wiring. Unlike tool kernels, processor
kernels are not DB-backed (there is no mastra_processor_kernel table).
If you are looking for the runtime hook, it is post-tool-processor.ts.
post-tool-processor.ts — the real runtime observer
A registered, ordered post-tool-execute stage. It replaced ad-hoc inline calls
inside create-condor-tool.ts, where output bounding and same-run card
buffering were two hardcoded calls mid-wrapper with no registry, no declared
order, and no per-behavior test seam.
Three hard rules:
- A processor can never fail the run. Every invocation is individually try/caught; a throw degrades to pass-through-unmodified plus a counted, logged warning.
- A processor must never mutate clinical data. Presentation-shaping and output-bounding only. (Note this is the data-mutation sense of the word — Axis 2 — and it is a prohibition, not a gate.)
- Context is declared, never ambient. An explicit
PostToolContextargument; zero env-var reads inside the stage, asserted structurally.
The stage exists to make a specific defect class detectable. Its header cites
the exemplar: UI_BLOCK_TOOL_NAMES in stream-derive.ts is a hand-maintained
literal set gating three behaviors at once (live data-ui-block frame emit,
same-run pin buffer append, render-before-pin serialization).
treatment.list.card was absent from it — one missing string, three silent
failures, all tsc-green. A registered stage with per-processor tests turns that
into a failing test.
claim-guard.ts — the post-hoc honesty observer
Not a mid-run gate. It runs over the final assistant text, after the run,
and rejects or corrects success claims in the model's closing prose that lack a
matching succeeded entry in the run's claim-evidence ledger. "Invitation
sent" requires a communication.send step actually to have succeeded — a
degraded evidence entry does not validate the claim, because a
partially-dropped action must not be papered over.
It is deterministic and additive: it appends a clearly-marked correction
(CLAIM_CORRECTION_PREFIX = '⚠ Correction:',
FAILURE_NOTICE_PREFIX = '⚠ Not completed:',
UNPLANNED_CLAIM_PREFIX = '⚠ Not executed:', claim-guard.ts:87-185) and
never re-words the model's prose in place — regex splices over LLM text are how
claims get mangled. DEFAULT_CLAIM_GUARD_RULES (claim-guard.ts:34) is an
explicit allowlist of claim patterns; new claim classes must be added there,
which is precisely how a missed class gets fixed.
The observer layer, summarized
| Observer | Scope | Can it fail the run? |
|---|---|---|
post-tool-processor.ts | per tool call, post-execute | no — try/caught per processor |
claim-guard.ts | final assistant text, post-run | no — appends corrections |
[plan.validate.structure] compiler pass | plan structure, compile-time | logging only |
processors/kernel-registry.ts | — | not a runtime hook at all |
Where this joins the neighbours
- HITL approval (Axis 2) —
condor-hitl-approvalowns thepolicyClass×hitlTierresolution, the suspend funnel, and the Approve/Reject resume path. This topic only names the join site (tool-call.ts:52) and the fact thatrequest-approvalfrom the plan-graph gate is a different trigger reaching the same Mastrasuspend()primitive. Two suspend paths ride that one primitive — a plan-authoredapprovalleaf (leaf-builders/approval.ts) and a runtime tool-policy suspend insidebuildToolCallStep, both using the sharedleaf-builders/suspend-sentinel.tsmarker. Conflating "an approval leaf ran" with "tool-policy fired" misattributes which mechanism gated a turn. - AG-UI frames —
condor-sse-pump-aguiowns the emitter → mapper → wire → consumer chain and the frame-prefix contract. This topic'semitPlanRevised/PlanRevisionoutput is a producer into that chain; the prefix rules there apply unchanged. - Compiler / planner —
condor-workflow-compilerandcondor-intent-and-plannerown how the graph got built in the first place. The invariant this topic adds: the mutation gate never recompiles mid-run — it edits within the already-compiled graph.
Traps, restated
- "The mutation gate approves data writes" is wrong. That is
tool-policy.ts's job. The mutation gate never touches FHIR or Prisma. processors/kernel-registry.tsis admin display metadata, not the runtime observer registry. The runtime observer ispost-tool-processor.ts.- Three "fix a bad plan" mechanisms (
plan-repair.service.tsone-shot candidate repair,plan-revision.tspublish-once bookkeeping, the replan-storm breaker) are not one "replan" story. - The replan breaker cannot be configured off — a
0/negative cap clamps up to the default floor. applySplice()throws.SpliceVerbis a documented contract with no implementation; do not plan against it.- Path trap:
mutation-gate.plan.ts,mutation-gate.evaluator.tsandstep-outcome.types.tslive atddx-api/src/ai/condor/root, whilemutation-gate.ts,mutation-engine.tsandmutation-decision.types.tslive undermastra/orchestrator/. Same conceptual subsystem, two folders.