13-ai-condorwave: W6filled18 citations

Condor Mutation, Replan and Observers — Two Unrelated Gates Sharing One Word

Audiences: developer, internal

Condor Mutation, Replan and Observers — Two Unrelated Gates Sharing One Word

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 mutationData mutation
What it changesthe compiled plan graph, mid-runa clinical record in the HMS
Why it firesa step failed / needs recoverya tool wants to CREATE / UPDATE / DELETE
Authority filemastra/orchestrator/mutation-gate.tstool-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 spaceCompiledGraphLedger stepId coordinatestool descriptor (policyClass × hitlTier)
Human involvementnone, normally — it is automatic error recoverythis is exactly what HITL approval gates
Touches FHIR/Prismaneverthat is its entire purpose
Documented inthis topiccondor-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.

Rendering diagram…

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 + supervisedrequest-approval — a plan mutation under supervision suspends through the approval gate before any re-scope
  • failed non-terminal + autonomousre-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:

  1. pure decision via mutation-gate.plan.ts::evaluateMutationGate
  2. separate-model confirm — confirmMutationWithSeparateModel (mutation-gate.evaluator.ts) when the fired rule is ambiguous
  3. apply the verb within the already-compiled graphno recompile mid-run — via skip-remaining or retry-with-different-tool (requeryToolsForOutcome, re-querying the same alias/RBAC path)
  4. emit every applied decision via emitPlanRevised (agui-event-store.service.ts) as a PlanRevisedEvent wire frame
  5. 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.

Rendering diagram…

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 maxReplansPerRun cap 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:

AssertionMeaning
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 capnot before, not after, never infinite
trips degraded when any leaf succeededpartial work is delivered honestly, not discarded
trips failed when no leaf succeedednothing to deliver
one replan does not tripthe cap allows real recovery
a 0 or negative cap clamps up to the default floorthe breaker can never be configured off
recordReplan also counts toward the mutation envelopereplans 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.

MechanismFileLifecycle pointWhat it does
One-shot LLM repairplan-repair.service.ts::repairOnceplan candidate is parse/schema-invalid or compile-invalid, before executioninvokes 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 bookkeepingplan-revision.tsplan is already published, then structurally edited mid-runemits 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 breakermutation-gate.ts CircuitBreakerLedgermid-run, across many stepsstops 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:

  1. 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.
  2. 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.)
  3. Context is declared, never ambient. An explicit PostToolContext argument; 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

ObserverScopeCan it fail the run?
post-tool-processor.tsper tool call, post-executeno — try/caught per processor
claim-guard.tsfinal assistant text, post-runno — appends corrections
[plan.validate.structure] compiler passplan structure, compile-timelogging only
processors/kernel-registry.tsnot a runtime hook at all

Where this joins the neighbours

  • HITL approval (Axis 2)condor-hitl-approval owns the policyClass × hitlTier resolution, the suspend funnel, and the Approve/Reject resume path. This topic only names the join site (tool-call.ts:52) and the fact that request-approval from the plan-graph gate is a different trigger reaching the same Mastra suspend() primitive. Two suspend paths ride that one primitive — a plan-authored approval leaf (leaf-builders/approval.ts) and a runtime tool-policy suspend inside buildToolCallStep, both using the shared leaf-builders/suspend-sentinel.ts marker. Conflating "an approval leaf ran" with "tool-policy fired" misattributes which mechanism gated a turn.
  • AG-UI framescondor-sse-pump-agui owns the emitter → mapper → wire → consumer chain and the frame-prefix contract. This topic's emitPlanRevised / PlanRevision output is a producer into that chain; the prefix rules there apply unchanged.
  • Compiler / plannercondor-workflow-compiler and condor-intent-and-planner own 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

  1. "The mutation gate approves data writes" is wrong. That is tool-policy.ts's job. The mutation gate never touches FHIR or Prisma.
  2. processors/kernel-registry.ts is admin display metadata, not the runtime observer registry. The runtime observer is post-tool-processor.ts.
  3. Three "fix a bad plan" mechanisms (plan-repair.service.ts one-shot candidate repair, plan-revision.ts publish-once bookkeeping, the replan-storm breaker) are not one "replan" story.
  4. The replan breaker cannot be configured off — a 0/negative cap clamps up to the default floor.
  5. applySplice() throws. SpliceVerb is a documented contract with no implementation; do not plan against it.
  6. Path trap: mutation-gate.plan.ts, mutation-gate.evaluator.ts and step-outcome.types.ts live at ddx-api/src/ai/condor/ root, while mutation-gate.ts, mutation-engine.ts and mutation-decision.types.ts live under mastra/orchestrator/. Same conceptual subsystem, two folders.