Migrating to 0.10
Harn 0.10 removes long-deprecated resilience and composition surfaces.
There are no compatibility shims. harn check and the runtime reject removed
LLM option names and report the canonical replacement.
Effects move onto the harness
This is the largest change in 0.10, and harn fix does nearly all of it for
you. Skip to Run the migration if you just want the
commands.
A function used to be able to read a file, print a line, or call a model without saying so in its signature. Every one of those effects was a global you could call from anywhere:
fn load(path: string) -> string {
return read_file(path)
}
Nothing in load's signature tells you it touches the disk. You had to read the
body, and the bodies it calls, to know what authority the function needs.
In 0.10 an effect is reached through a capability handle that the function receives as an argument. A signature now states what a function can do:
fn load(harness: HarnessFs, path: string) -> string {
return harness.read_text(path)
}
Authority enters at your entrypoint, which still takes the whole Harness, and
flows down to each function as the narrowest handle that covers its work. A
function holding a HarnessFs cannot open a socket, and you can see that
without reading it. Tests get the same benefit: pass a stub handle instead of
mocking a global.
Run the migration
harn fix rewrites the calls, adds the parameters, and threads the handles
through every caller in one pass:
harn fix ./src --apply --dry-run --capability-migrations-only --safety surface-changing
harn fix ./src --apply --capability-migrations-only --safety surface-changing
Start with --dry-run to see the repair count without writing. Drop it to
apply. --capability-migrations-only keeps the pass to this migration and
leaves unrelated repairs alone. The migration changes public signatures, so it
needs --safety surface-changing.
The pass converges: it reports post-apply diagnostics: 0 when nothing is left
to migrate. When a typed helper now requires a leading capability argument,
the same pass adds or widens one carrier and updates local and imported
callers. Multiple requirements become one closed capability bundle instead of
parallel parameters. Every edited Harn file is formatted with the nearest
project's [fmt] settings before the command returns. Commit, then run
harn check ./src to confirm.
Flow predicates are a deliberate boundary. Flow evaluation injects
HarnessAst, so the migration can thread AST authority into an @invariant.
It stops with an explicit error if that predicate also reaches another effect,
such as the filesystem. Move that effect outside the predicate rather than
granting authority the evaluator cannot supply. harn check enforces the same
rule on manually authored @invariant parameters: only a leading
HarnessAst capability is valid.
What the rewrite looks like
Given a file where two helpers use ambient globals:
fn log_line(message: string) {
println(message)
}
fn load(path: string) -> string {
return read_file(path)
}
fn main(harness: Harness) {
log_line(load("notes.txt"))
}
harn fix produces:
fn log_line(harness: HarnessStdio, message: string) {
harness.println(message)
}
fn load(harness: HarnessFs, path: string) -> string {
return harness.read_text(path)
}
fn main(harness: Harness) {
log_line(harness.stdio, load(harness.fs, "notes.txt"))
}
Each helper takes the one handle it uses, and main splits its root grant at
the call sites. A function spanning two capabilities takes a record of the
handles it needs:
fn describe(harness: {fs: HarnessFs, system: HarnessSystem}) -> string {
return harness.system.platform().os + " " + harness.fs.home_dir()
}
fn main(harness: Harness) {
harness.stdio.println(describe({fs: harness.fs, system: harness.system}))
}
Keep the root Harness at entrypoints and at boundaries that genuinely
coordinate several capabilities. Everywhere else, take the narrowest handle.
Globals that became a field on a snapshot
A few globals returned one value where the capability now returns a structured
snapshot. harn fix handles these too, but they are worth recognizing in a
diff:
| Removed global | Replacement |
|---|---|
platform() | harness.system.platform().os |
arch() | harness.system.platform().arch |
username() | harness.system.identity().username |
read_file(path) | harness.fs.read_text(path) |
metadata_get(dir, ns) | harness.project.metadata_get({dir: dir, namespace: ns}) |
If you need to migrate by hand
The linter names the capability for each remaining call. HARN-LNT-071 covers
globals generally; HARN-LNT-052 through HARN-LNT-057 call out clock, stdio,
fs, env, random, and net specifically. Each explanation is available with
harn explain <code>.
Two related rules fire once the handles are in place. HARN-LNT-069 reports a
function holding more authority than it uses, and points at the narrower handle
to take instead. HARN-LNT-070 reports a public function taking four or more
positional parameters of the same type, where a caller can swap two and still
typecheck.
Staging a large upgrade
An unmigrated call is a hard error, so a large codebase cannot land the upgrade
one package at a time without help. Set HARN_LEGACY_AMBIENT_CAPABILITIES=1 to
let the removed globals resolve while you convert:
HARN_LEGACY_AMBIENT_CAPABILITIES=1 harn check ./src
Strict enforcement is the default and stays on unless the variable is set to
1, true, yes, or on — an unset, empty, or 0 value keeps failing
closed. The bridge only recognizes names that the typed registry already owns,
so a misspelled global is still undefined with the bridge on.
This is a transition aid for staging a migration across many packages, not a
supported long-term mode. Drop the variable as soon as harn check passes
without it; that passing run is what proves the migration is done.
host_call has no single replacement
host_call is the one removal on this page that a table cannot cover, because
it was a generic dispatcher rather than one operation. It is declared
privileged_wire: the runtime and the host bridge still call it, but Harn
source cannot name it. HARN-LNT-072 reports each remaining call site.
In practice most calls do have a destination, because the namespace in the
host_call string is usually a capability name:
host_call("ast.outline", {path: p}) // before
harness.ast.outline(p) // after
You do not have to look most of them up. When the operation name is a string
literal, HARN-LNT-072 resolves it against the declared contracts and names the
destination in the diagnostic, spelling differences included — a
prmonitor.run_commands target reports harness.pr_monitor.run_commands. A
computed operation name cannot be resolved, so those calls get the generic route
and you look them up with harn contracts builtins, which lists every declared
harness.<capability>.<method> with its handle type.
Arguments do not carry over unchanged, and the diagnostic does not guess them:
host_call packed one dict keyed by the host's parameter names, and a typed
method takes the parameters its own signature declares.
An operation with no declared contract has no capability method to move to.
Register it with register_callable_host_operation, install the callable root
with apply_callable(root), and reach it as <root>.<operation> with an
argument shape you choose.
Scope the work with harn lint, not harn check. The checker reports an
undefined name; the linter reports which surface replaced it, and
harn fix --apply --safety surface-changing rewrites the calls that have a
mechanical repair.
Run harn check on every file harn fix touched before committing the result.
Since #6161 the apply path groups every edit for a file by canonical path and
parse-checks the candidate before writing, so it no longer places an inserted
argument at a stale byte offset (#6148) and no longer reports success over
source the parser rejects. One defect is still open: #6149 over-attenuates
connector runtime exports, and the result looks plausible in a diff. Applying
per file, or in small batches, keeps the blast radius readable.
Read the output on stderr, not stdout. harn lint and harn check write every
human-readable line there — findings and the clean-file line alike — so
2>/dev/null silences a sweep rather than trimming it.
LLM call options
The llm_call family, streams, and agent_loop dispatch now share one option
registry. Update option dicts before upgrading the runtime; stale computed dicts
fail at the call boundary instead of losing fields during projection.
Update a call
- Replace each removed top-level key using the table below.
- Move provider-specific knobs below
provider_options.<provider>. - Run
harn check <path>to find removed names in literals. - Exercise any computed option builders. Runtime validation covers shapes the checker cannot know statically.
// Before
const old = harness.llm.call(prompt, nil, {
output_schema: schema,
output_validation: "error",
reasoning_effort: "high",
system_prefix: "Be precise.",
timeout: 120,
ollama: {num_ctx: 32768},
})
// After
import {system_before} from "std/llm/prompts"
const current = harness.llm.call(prompt, nil, {
output: {schema: schema, validation: "error"},
effort: "high",
system: [system_before("Be precise.")],
timeout_ms: 120000,
provider_options: {ollama: {num_ctx: 32768}},
})
Replacement table
| Removed | Replacement |
|---|---|
schema, json_schema, output_schema | output: schema or output: {schema, ...} |
response_format, output_format | output: "json" or output: {schema, ...} |
output_validation | output: {schema, validation: ...} |
schema_stream_abort | output: {schema, stream_abort: ...} |
llm_repair | repair |
system_preamble, system_prefix, system_context | system: [{content, position: "before"}] |
system_appendix, system_suffix | system: [{content, position: "after"}] |
system_prompt_parts | system |
caps, project_context_profile | capabilities, context_profile |
api, role | api_mode, model_role |
prefer, fallback_strategy, strategy | route_policy |
model_ladder | models for inline steps; ladder for a catalog name |
budget_usd | budget: {max_cost_usd: ...} |
reasoning_effort | effort |
thinking_policy, problem_scale, task_kind, task | reasoning_policy, reasoning_scale, reasoning_task |
hosted_tools | provider_tools |
response_store, responses_store | store |
timeout, idle_timeout | timeout_ms, idle_timeout_ms (milliseconds) |
fast | speed: "fast" |
top-level provider names such as openai or ollama | provider_options: {openai: {...}} |
transcript | open/resume a session and pass session_id |
The complete live surface is in the llm_call reference.
llm_retries / llm_backoff_ms (removed)
The per-call transient-retry options on llm_call-family surfaces and
agent_loop are gone. llm_call and agent_loop are now fail-fast on
transient provider errors (429 / 5xx / connection); retry policy is
composed on the caller seam with with_retry from std/llm/handlers.
Mind the off-by-one: llm_retries: K counted retries after the
first attempt, while with_retry's max_attempts counts total
attempts, so llm_retries: K → with_retry(..., {max_attempts: K + 1}).
Before (0.9)
const r = harness.llm.call("prompt", nil, {llm_retries: 2, llm_backoff_ms: 500})
After (0.10)
import {default_llm_caller} from "std/llm/caller"
import {with_retry} from "std/llm/handlers"
const caller = with_retry(default_llm_caller(), {max_attempts: 3, base_ms: 500})
const envelope = caller({prompt: "prompt", system: nil, opts: {}})
For agent_loop, wire the composed caller into the llm_caller: seam:
const result = agent_loop(harness, task, system, {
loop_until_done: true,
llm_caller: with_retry(default_llm_caller(), {max_attempts: 3}),
})
Behavior changes that ride along:
agent_loopprofiles no longer default to two transient retries. The pre-0.10 profiles injectedllm_retries: 2; loops that relied on that implicit resilience must opt in viallm_caller:+with_retry— or build their options withagent_preset(kind, ...), which bakes the equivalent bounded transport retry (with_retry,max_attempts: 3) onto the effectivellm_caller:by default (retry: falseopts out).- The zero-token empty-completion retry is now a fixed built-in: one
silent retry per call for provider-shaped routes (none for the
mockprovider, whose scripted turns must stay deterministic). It is no longer widened byllm_retries. - The proactive per-route rate limiter, shared 429/overload cooldowns, the network circuit breaker, and the one-shot tool-channel / stream-transport degrades are unchanged — they never depended on the removed options.
std/agent/stack (removed)
The std/agent/stack module and its composition bundle are gone:
agent_stack, agent_llm_caller, agent_tool_stack,
agent_stack_audit_line, and agent_stack_model_policy no longer
exist. Compose the pieces directly:
- Model/route resolution:
agent_model_optionsandagent_sanitize_model_optionssurvive and now live instd/agent/options(same signatures; theaudit.kindlabel changed fromagent_stack.model_optionstoagent_model_options). They are ordinary module exports now, not ambient builtins — addimport {agent_model_options} from "std/agent/options". - LLM middleware:
compose/with_retry/with_logging/with_cache/with_budgetfromstd/llm/handlerson thellm_caller:seam. - Tool middleware:
with_required_reason,compose_tool_callers, andtools_use_middlewarefromstd/llm/tool_middlewareon thetool_caller:seam. agent_stack_model_policy's projection is apick_keys(options, [...], {drop_nil: true})call with the stable model-policy keys.
Before (0.9)
import {agent_stack} from "std/agent/stack"
const stack = agent_stack({role: "planner", retry: {max_attempts: 3}, required_reason: true})
const result = agent_loop(harness, task, system, stack.options + {loop_until_done: true})
After (0.10)
import {agent_model_options} from "std/agent/options"
import {default_llm_caller} from "std/llm/caller"
import {with_retry} from "std/llm/handlers"
import {compose_tool_callers, with_required_reason} from "std/llm/tool_middleware"
const route = agent_model_options({role: "planner"})
const mw = with_required_reason({})
const result = agent_loop(harness, task, system, route.options + {
loop_until_done: true,
llm_caller: with_retry(default_llm_caller(), {max_attempts: 3}),
tool_caller: compose_tool_callers([mw.caller]),
})
*_agent preset wrappers and agent_budget (removed)
The eight one-line wrappers in std/agent/presets — audit_agent,
repair_agent, summary_agent, verify_agent, merge_captain_agent,
review_captain_agent, oncall_captain_agent,
release_captain_agent — are gone. agent_preset(kind, options?) is
the single preset surface; spread its result into agent_loop:
Before (0.9)
import {audit_agent} from "std/agent/presets"
const result = audit_agent("Audit the release", {tools: tools})
After (0.10)
import {agent_preset} from "std/agent/presets"
const opts = agent_preset("audit", {tools: tools})
const result = agent_loop(harness, "Audit the release", opts?.system, opts)
agent_budget(kind_or_options, overrides?) is also gone. Preset kinds
already install their default iteration_budget; to override, pass an
iteration_budget dict (or the "adaptive" string sugar) directly:
const opts = agent_preset("repair", {iteration_budget: {mode: "adaptive", initial: 4, max: 24, extend_by: 2}})
transcript_policy (verified gone)
The pre-0.7 transcript_policy dict on workflow nodes and the
workflow_set_transcript_policy builtin were already removed in 0.7 —
sessions are the sole surface (see
Migrating from 0.6.x to 0.7.0). 0.10 re-verified that no
acceptance remains. Note that the worker-resume transcript policy
(continue_transcript on worker resume/replay) and the task-plan IR's
forward-declared transcript_policy metadata field are different,
still-supported mechanisms.
harn_vm::mcp_protocol moves to stable MCP (Rust embedders)
This section is only for Rust code that embeds harn-vm and drives MCP
itself. .harn scripts, the CLI, and harn serve are unaffected — they
never named these symbols.
0.10.53 moved the MCP client and server onto stable rmcp 3.1 and made
MCP 2026-07-28 the sole Harn-owned server contract. The release-candidate
surface went with it: there is no negotiation left, so the types that carried
a negotiated result no longer carry anything.
PROTOCOL_VERSION kept its name and changed its meaning
Read this one before the rename table. There used to be four version
constants, and PROTOCOL_VERSION was the newest stable one:
| Constant | Before | After |
|---|---|---|
PROTOCOL_VERSION | "2025-11-25" | "2026-07-28" |
DRAFT_PROTOCOL_VERSION | "DRAFT-2026-v1" | removed |
LEGACY_2025_06_18_PROTOCOL_VERSION | "2025-06-18" | removed |
LEGACY_2024_11_05_PROTOCOL_VERSION | "2024-11-05" | removed |
So PROTOCOL_VERSION still compiles and now means something else. Code that
branched on "draft versus stable" and reached for PROTOCOL_VERSION on the
stable arm silently becomes correct-by-collapse; code that used it as "the
older version we still accept" silently becomes wrong. Grep for it rather than
trusting the compiler.
Renamed
The RC_META_KEY_* values are unchanged — those are pure constant renames. The
RC_HEADER_* values are not: see the casing trap below.
| Removed | Replacement |
|---|---|
DRAFT_PROTOCOL_VERSION | PROTOCOL_VERSION (different value — see above) |
rc_name_header_value | standard_name_header_value |
negotiate_rc_http_request | negotiate_http_request |
apply_rc_result_envelope(result, mode, cache) | apply_result_envelope(result, cache) |
RC_HEADER_PROTOCOL_VERSION | MCP_HEADER_PROTOCOL_VERSION |
RC_HEADER_METHOD | MCP_HEADER_METHOD |
RC_HEADER_NAME | MCP_HEADER_NAME |
RC_META_KEY_PROTOCOL_VERSION | MCP_META_KEY_PROTOCOL_VERSION |
RC_META_KEY_CLIENT_INFO | MCP_META_KEY_CLIENT_INFO |
RC_META_KEY_CLIENT_CAPABILITIES | MCP_META_KEY_CLIENT_CAPABILITIES |
Removed outright
McpProtocolMode, withis_modern()anddefault_protocol_version(). There is one protocol, so there is no mode to branch on. Any struct of yours that stored a mode alongside a request and response version can usually be deleted rather than ported:negotiate_http_requestrejects any header that is notPROTOCOL_VERSION, andenforce_request_protocol_versionrejects any_metaversion that is not the same constant, so all three fields were provably that one constant by the time you could read them. Keep the check; drop what it returned.MCP_SESSION_HEADER_LEGACY. Sticky session ids are gone from both request and response, so assertions that a response lacks the header now have nothing to assert.- The
initializemethod.server/discoveris the sole lifecycle handshake.
Changed signatures and behavior
enforce_request_protocol_versionreturnsResult<(), JsonValue>rather thanResult<Option<McpProtocolMode>, JsonValue>, and it now requires_meta. Every request must carryio.modelcontextprotocol/protocolVersion,io.modelcontextprotocol/clientInfo, andio.modelcontextprotocol/clientCapabilities. A request that omits them is rejected where it previously fell through to a default.McpRequestMetadatais nowrmcp::model::RequestMetaObject, sometadata.protocol_versionis a method returningOption<ProtocolVersion>, not aOption<String>field.- A header that contradicts the JSON-RPC body now returns the SDK's
HEADER_MISMATCH_CODE(-32020) rather than-32600.
Two traps worth naming
Both of these compile cleanly and fail at run time, so a green cargo check
is not evidence you have finished the migration.
HeaderName::from_static panics on these constants. The header names were
Harn-owned and lowercase ("mcp-protocol-version", "mcp-method",
"mcp-name"); they now come from the SDK and are canonically cased
("MCP-Protocol-Version", "Mcp-Method", "Mcp-Name"). from_static
requires an already-lowercase name, so a call site that was fine for years
starts panicking on a pure constant rename. Parse it instead:
// Panics: "HeaderName::from_static with invalid bytes"
HeaderName::from_static(mcp_protocol::MCP_HEADER_PROTOCOL_VERSION)
// Correct
HeaderName::from_bytes(mcp_protocol::MCP_HEADER_PROTOCOL_VERSION.as_bytes())
The same applies when you read a captured header out of a plain map:
HeaderName::as_str() lowercases, so indexing that map with the constant
finds nothing. HeaderMap::get is case-insensitive and needs no change.
server_discover_result has a different shape than initialize did. It
advertises supportedVersions (a list) rather than a single protocolVersion,
and it reports identity under _meta["io.modelcontextprotocol/serverInfo"]
rather than a top-level serverInfo. Indexing a Value at a missing key
yields Null, so a test comparing the old path to a concrete version fails
with Null on the left — a confusing message for a path that simply moved.