Skip to content

Supervise & Heal

supervise is Sema’s structural recovery construct, and heal is its most powerful — and most tightly governed — rung. Where an ordinary program crashes or silently retries forever, a supervised scope follows a fixed triage ladder: clean-state restart, then a contract-declared fallback, then — only if you opted in — a synthesized repair that must pass an acceptance gauntlet before it touches anything.

The design lesson, borrowed from Erlang, is that recovery policy is structural, not a flag: supervision scopes with blast-radius scoping and restart budgets. And the LLM lesson is that intrinsic self-repair without external grounded feedback often costs more than resampling and can degrade results — so healing is deliberately the last option, budgeted and gated.

Why healing is a supervision-scope property

Section titled “Why healing is a supervision-scope property”

A program-wide “self-heal” mode is the wrong shape: it has no blast radius, no restart budget, and no acceptance criterion. Sema makes healing a property of a supervise scope with a fixed triage ladder and a journaled acceptance gauntlet. Recovery escalates only as far as it must, everything is journaled, and no step is ever silent.

supervise ingest_workers:
restart limit=3 # Armstrong first: clean-state retry
fallback cached_summaries() # contract-declared degraded mode
heal budget=2: # synthesis is the LAST rung
require smoke_suite_passes() # gates are ordinary boolean expressions
require not regression_detected()
rollout shadow -> canary -> full # stages recorded in the journal
return batch_work() # the supervised body follows the clauses

The config clauses come first, in the same block as the work they protect. Two honesty notes up front:

  • restart window="…", heal window="…", and heal scope=… parse and are recorded in the journal, but are not enforced yet — they are §5.11 target spec. sema check warns on each of them so a program cannot silently rely on a time-window or scope bound that nothing implements. restart limit= and heal budget= are the enforced bounds.
  • lane and enter are not supervise vocabulary. They never had semantics inside a def body, and sema check now flags them as unrecognized statements instead of accepting them silently. lane is a real clause in worker execution profiles (worker Pool: lane best_effort), where it governs parallel … by scheduling.

What actually happens at runtime, with the journal record written at every edge:

flowchart TD
E["supervise <scope>: entered"] -->|"journal: supervise.enter"| A["run the body (attempt n)"]
A -->|"no failure"| OK["scope completes normally"]
A -->|"journal: supervise.failure"| R{"restart limit\nleft?"}
R -->|"yes — journal: decision restart"| A
R -->|"exhausted"| H{"heal declared and\nattempts < budget?"}
H -->|"yes"| G["heal gauntlet:\nmodel proposes a minimal patch"]
G --> Q{"all require gates hold?\n(each gate journaled:\ndecision heal.gate)"}
Q -->|"accepted — journal: modification heal.patch\nstages journaled: decision heal.rollout"| M{"apply mode"}
M -->|"live / persistent — hot-swap;\njournal: decision heal (retry)"| A
M -->|"staged (default) — recorded\nfor external application"| F
Q -->|"any gate fails — patch rejected"| F{"fallback\ndeclared?"}
H -->|"no — budget spent\nor no heal clause"| F
F -->|"yes — journal: decision fallback\n(value evaluated, then discarded)"| C["scope recovers; execution\ncontinues after the block"]
F -->|"no"| X["failure re-raised\nto the caller"]
Supervise execution order: entering the scope journals supervise.enter; each body failure journals supervise.failure; while restart limit remains, decision restart re-runs the body; on exhaustion, if heal is declared and gauntlet attempts remain under budget, the model proposes a minimal patch and every require gate is journaled as decision heal.gate; an accepted patch is journaled as modification heal.patch with rollout stages as decision heal.rollout, and in live mode hot-swaps and re-runs the body; otherwise the declared fallback is evaluated, journaled as decision fallback with its value discarded, and execution continues after the block — or the failure is re-raised when no fallback exists. Supervise execution order: entering the scope journals supervise.enter; each body failure journals supervise.failure; while restart limit remains, decision restart re-runs the body; on exhaustion, if heal is declared and gauntlet attempts remain under budget, the model proposes a minimal patch and every require gate is journaled as decision heal.gate; an accepted patch is journaled as modification heal.patch with rollout stages as decision heal.rollout, and in live mode hot-swaps and re-runs the body; otherwise the declared fallback is evaluated, journaled as decision fallback with its value discarded, and execution continues after the block — or the failure is re-raised when no fallback exists.

Two consequences worth reading twice:

  • The fallback value is discarded. fallback <expr> is evaluated and journaled, but it is not the value of the scope. The scope recovers and execution continues after the supervise block — a return inside the supervised body that never succeeded does not happen. Fallback is a declared degraded action, not a substitute return value.
  • The body re-runs from the top on every restart — clean-state retry means the whole supervised body, not the failing statement.
Clause Semantics Status
restart limit=N At most N clean re-runs of the body per scope entry; each one journals decision: restart Enforced
restart window="…" Restart-intensity window Recorded in the journal only; sema check warns (§5.11 target)
fallback <expr> On exhaustion: evaluated, journaled (decision: fallback), value discarded, scope recovers Enforced
heal budget=N: At most N gauntlet attempts per scope entry. Non-positive or non-integer budgets are a typed error — fail-closed, never a silent default Enforced
heal window="…" / heal scope=… Heal-rate window / blast-radius scope Recorded in the journal only; sema check warns (§5.11 target)
require <expr> (inside heal:) An acceptance gate — an ordinary boolean expression, evaluated after the patch proposal; every gate journals decision: heal.gate with pass/fail Enforced
rollout a -> b -> c (inside heal:) Deployment stages of an accepted patch, journaled as decision: heal.rollout per stage Journal-recorded (observed) only
rollout outside heal: Nothing — parsed but ignored sema check warns: move it under heal:
lane / enter Nothing — removed from supervise vocabulary sema check flags them; lane belongs to worker profiles

When restarts are exhausted and the gauntlet budget (heal budget=N) has attempts left, the runtime:

  1. Proposes a patch. The failure (error, location, source context) is packed into a prompt and the configured model is asked for a minimal fix, capped at 128 output tokens. The call and the proposal are journaled (decision: model.invoke, decision: heal.suggestion). With no generation model configured, the deterministic engine returns a placeholder proposal — which the gates then judge like any other candidate.
  2. Evaluates every gate. Each require in the heal: block is an ordinary boolean expression evaluated in the enclosing scope. Every gate lands in the journal as decision: heal.gate with its source text and pass/fail result. A gate that raises is a failed gate — fail-closed.
  3. Accepts or rejects atomically. Any failed gate rejects the candidate (decision: heal with verdict: rejected) and the ladder falls through to fallback. If every gate holds, the patch is journaled as a substantial modification (modification: heal.patch, status staged or applied), and each rollout stage is journaled as decision: heal.rollout.
  4. Applies per the configured mode — see the next section. A hot-swapped patch (live or persistent mode) re-runs the body (journaled as decision: heal with attempt/of counters against the budget); a staged one is recorded for external application and the scope recovers via fallback.

A hot-swapped patch that fails again re-enters the ladder and may trigger another gauntlet run — until budget=N is spent, after which the scope falls through to fallback (or re-raises).

Applying a patch: staged, live, persistent

Section titled “Applying a patch: staged, live, persistent”

Patch acceptance (the gauntlet) and patch application are separate switches. Application is governed by [heal] apply in sema.toml, overridable by the SEMA_HEAL_LIVE environment variable:

Mode [heal] apply SEMA_HEAL_LIVE What an accepted patch does
staged (default) "staged" 0, false, off, staged Recorded in the journal only (modification: heal.patch, status staged). Running code is never rewritten; applying the patch stays an external, human path.
live "live" 1, true, live, in-process, ephemeral Erlang-style in-process hot swap: the patched function body replaces the running one and the supervised body re-runs. Ephemeral — gone at process exit.
persistent "persistent" persistent, persist, durable Live, plus the patch is committed to a hash-chained durable ledger, replayed at load, and managed via code.patches() / code.revert().

Both switches parse fail-closed: any other value is a typed error at startup (SEMA_HEAL_LIVE must be staged|live|persistent (or 0|1)), never a silent default. Unknown values never grant self-modification authority.

The debugger is honest about what can and cannot fire inside supervise:

  • Breakpoints in the supervised body fire on every attempt. The statement hook runs per statement per attempt, so a breakpoint in the body pauses on the first run and on each restart — you can watch the state the retry sees.
  • Breakpoints on config-clause lines never fire — and now say so. Lines holding restart, fallback, on_error, and everything inside a heal: body are configuration consumed when the scope is set up; the heal gates are evaluated by the gauntlet, outside the statement hook. A breakpoint set there verifies as false with an honest reason instead of silently never triggering.
  • The fallback expression is hookless, but the functions it calls are not. You cannot break on the fallback cached_summaries() line itself; set the breakpoint inside cached_summaries and it pauses normally when the ladder reaches the fallback rung.
  • Live patches are journal-only sources. A hot-swapped body is not registered into the debugger’s source map, so stepping through a live-patched function is line-misaligned relative to the file on disk, and debug snapshots record none_at_publication for its source. For post-mortem patch debugging use persistent mode: ledger patches are immutably captured as patch:<id> snapshot origins, so the source the process actually ran is the source you step through.

Worked example: failure → restart → heal rejected → fallback

Section titled “Worked example: failure → restart → heal rejected → fallback”

A complete program, run under the deterministic engine ([engine] deterministic = true) with the default staged apply mode. The body always fails; the restart budget is spent; the gauntlet proposes a patch, one gate rejects it; the fallback recovers the scope:

def parse_batch(path: str) -> int !{}:
ensure 1 == 2 # the batch always fails: drives the ladder
return 0
def main() -> int !{}:
supervise ingest:
restart limit=1
fallback 0
heal budget=1:
require 1 == 1 # first gate holds ...
require 1 == 2 # ... second gate rejects the candidate
rollout shadow -> canary -> full
return parse_batch("statements.csv")
print("recovered; execution continues after the block")
return 0

sema run prints the post-recovery line and exits 0. The journal (.sema/runs/<run-id>/journal.jsonl, hash-chain and timestamps elided) records every rung:

{"seq": 2, "kind": "supervise.enter", "scope": "ingest"}
{"seq": 3, "kind": "supervise.failure", "scope": "ingest", "attempt": "0", "error": "ContractViolation: ensure failed in parse_batch(): 1 == 2 [main.sema:2:12]"}
{"seq": 4, "kind": "decision", "decision": "restart", "scope": "ingest", "attempt": "1", "of": "1"}
{"seq": 5, "kind": "supervise.failure", "scope": "ingest", "attempt": "1", "error": "ContractViolation: ensure failed in parse_batch(): 1 == 2 [main.sema:2:12]"}
{"seq": 6, "kind": "decision", "decision": "heal", "scope": "ingest", "verdict": "attempt", "attempt": "1", "budget": "1", "window": ""}
{"seq": 7, "kind": "decision", "decision": "model.invoke", "verdict": "ok", "model": "builtin-mock", "backend": "deterministic-mock", "max_tokens": "128", "prompt_preview": "A supervised Sema scope failed. Propose the minimal fix. ## Error **ContractViolation** — ensure…"}
{"seq": 8, "kind": "decision", "decision": "heal.suggestion", "scope": "ingest", "patch_preview": "(configure [models] generate for a heal suggestion)"}
{"seq": 9, "kind": "decision", "decision": "heal.gate", "scope": "ingest", "gate": "1 == 1", "result": "pass"}
{"seq": 10, "kind": "decision", "decision": "heal.gate", "scope": "ingest", "gate": "1 == 2", "result": "fail"}
{"seq": 11, "kind": "decision", "decision": "heal", "scope": "ingest", "verdict": "rejected", "reason": "a required acceptance gate did not hold"}
{"seq": 12, "kind": "decision", "decision": "fallback", "scope": "ingest", "value": "0"}
{"seq": 13, "kind": "print", "text": "recovered; execution continues after the block"}

Read the ladder off the records: one failure per attempt (supervise.failure), one decision: restart while the limit lasts, the gauntlet’s model call and suggestion, one decision: heal.gate per gate with its verbatim source text, the atomic rejection, and the journaled-then-discarded fallback value. Note what is absent: no modification: heal.patch and no decision: heal.rollout — a rejected candidate stages nothing and rolls out nothing.

Record When Fields to know
supervise.enter Scope entered scope
supervise.failure A body attempt failed scope, attempt, error
decision: restart Clean re-run while limit lasts attempt, of (the limit)
decision: heal (verdict: attempt) Gauntlet begins attempt, budget, recorded window
decision: model.invoke The patch-proposal model call backend, max_tokens (128), prompt_preview
decision: heal.suggestion The proposed patch patch_preview
decision: heal.gate Each require gate gate (source text), result: pass|fail|error, error (the raised message, if any)
modification: heal.patch All gates held status: staged|applied, the patch
decision: heal.rollout Each rollout stage of an accepted patch stage
decision: heal (verdict: applied) Hot-swapped patch re-runs the body attempt, of (the heal budget)
decision: heal (verdict: rejected) A gate failed reason
decision: fallback Fallback evaluated; scope recovers value (journaled, then discarded)
  • sema check validates that heal appears only inside supervise, and warns honestly on every accepted-but-unenforced clause: restart window=, heal window=, heal scope= (“recorded in the journal but not enforced yet”), and rollout outside a heal: block. lane and enter in a def body are flagged as unrecognized statements.
  • heal budget= is validated at runtime, fail-closed: a non-positive or non-integer budget raises a typed HealConfigError that even the scope’s own fallback does not swallow — never a silently clamped value.
  • Every attempt — restart, gate verdict, acceptance, rejection, rollout stage, fallback — is a journal record in the hash-chained run journal, so a healing episode is auditable and replayable after the fact.
  • Monitors & Drift — drift verdicts and degrade as the model-swap alternative to a code patch.
  • Policies — the policy envelope healing runs under.
  • Provenance & Trust — trust labels and why untrusted data cannot be endorsed by a healer.
  • Reflection & Staged Codecode.patches(), code.revert(), and the staged-code path that patch application shares.