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.
Tool registry construction uses one options record#
tool_registry_from now accepts registry-wide metadata through one typed
options record. Replace positional info and components arguments:
const registry = tool_registry_from(specs, info, components)
with named fields:
const registry = tool_registry_from(specs, {
info: info,
components: components,
cli: cli,
})
Calls that pass only specs do not change. There is no positional compatibility
shim. The record keeps registry-wide schema components and CLI parent-command
metadata extensible without adding another positional parameter.
Pace decisions become cut rules#
Pace decisions are no longer part of std/agent/governors. Update imports and
names in the same change that updates Harn. There are no compatibility exports.
| Removed | Replacement |
|---|---|
std/agent/governors::governor_pace_decision | std/agent/cut_rules::pace_cut_rule_decision |
std/agent/governors::governor_pace_extend_max | std/agent/cut_rules::pace_cut_rule_extend_max |
std/agent/governors::governor_pace_check_max_injections | std/agent/cut_rules::pace_cut_rule_check_max_injections |
std/agent/governors::pace_action_of | std/agent/cut_rules::pace_cut_rule_action_of |
GovernorPolicy fields extend_max and pace_check_max | PaceCutRulePolicy fields with the same names |
Apply the migration in this order:
- Publish the Harn release that contains
std/agent/cut_rules. - In one consumer change, update the Harn version, imports, and function names.
- Keep budget governor policies separate from
PaceCutRulePolicyvalues. - Run
harn checkon each updated package before merging that change. - Release the consumer only after it uses the new Harn version and names.
The decision inputs, action values, reason values, and default limits do not change. Only module ownership and public names change.
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 harness.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 runtime no longer creates internal response_format or json_schema
mirrors after parsing output. Provider dialects lower the typed output
value directly. Code that constructs computed option dicts must therefore use
the replacement above; no later adapter projection restores a removed key.
The complete live surface is in the harness.llm.call reference.
Check portable options against the selected route#
top_logprobs was removed. Put the alternative count in the value that
enables log probabilities:
// Before
{logprobs: true, top_logprobs: 3}
// Now
{logprobs: {top: 3}}
Harn no longer treats an unsupported portable generation or prompt-cache
option as a hint that an adapter may discard. Caller-selected temperature,
top_p, top_k, logprobs, logit_bias, min_p, repetition_penalty,
prediction, verbosity, mirostat, seed, frequency_penalty,
presence_penalty, stop, parallel_tool_calls, cache: true, and
prompt_cache_ttl are admitted before provider transport. An explicit
incompatibility throws a terminal invalid_request error.
Run harn provider catalog matrix to inspect supported routes. Remove the
portable option, choose a compatible route, or move a genuinely provider-native
control below provider_options.<provider>. Provider-native spellings of a
first-class field are rejected there. Catalog defaults do not count as caller
intent. Unknown custom routes remain open-world for the original scalar
controls, but advanced generation controls require an authored
advanced_generation_options lowering. Custom prompt-cache routes must declare
prompt_caching and any selectable prompt_cache_ttls in harn.toml because
their wire lowering is provider-specific.
llm_retries / llm_backoff_ms (removed)#
The per-call transient-retry options on harness.llm.call-family surfaces and
agent_loop are gone. harness.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.
Model policy now comes from catalog and capability data#
std/llm/defaults, built-in agent_preset rows, and std/agent/sitrep no
longer branch on model IDs, providers, or model lineages. The runtime
capability matrix owns reasoning lowering, while named [model_ladders.*]
catalog rows own ordered provider/model routes.
Calls to pack_for keep the same public signature. If you depended on its old
family-specific temperature or output-token calibration, set those portable
options explicitly or author them as model defaults in the catalog. The
canonical reasoning controls remain reasoning_policy, reasoning_scale, and
reasoning_task; explicit thinking or effort still bypasses policy
lowering.
Built-in agent presets now return ladder: "agent_frontier" or
ladder: "agent_cheap" instead of copying provider plus models rows into
the options dict. Treat route selection as an ownership group rather than
inspecting that old projection:
import {agent_preset} from "std/agent/presets"
const opts = agent_preset(harness, "audit")
const routes = harness.llm.model_ladder(opts.ladder).steps
agent_sitrep_preferred_routes now takes harness.llm because it projects the
catalog-owned sitrep ladder:
const routes = agent_sitrep_preferred_routes(harness.llm)
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.
Handle a missing session store#
Session-store reads no longer make a missing store look empty. The public read
functions and the matching harness.agent methods now return
SessionStoreRead<T>:
const read = session_store_events(harness.agent, session_id)
if read.state == "absent" {
// Choose whether a fresh workspace is valid or the root is wrong.
return
}
const events = read.value
An existing empty store returns {state: "present", value: []}. A missing
store returns {state: "absent"} and has no value field. This also applies
to list, payload, projection, verification, and search reads.
Reads still do not create files. Call session_store_append or start the
session lifecycle before a read when your code intends to create the store.
For session_store_project_value, the default applies only when the store is
present. Decide what an absent store means before reading value.
Read builtin contracts as schema version 3#
harn contracts builtins now emits schema version 3. Every builtin row adds a
runtime_control_plane boolean, which distinguishes Harn-owned session state
from mutations of the user's workspace or an external system. Consumers that
validate the top-level version must accept 3 before reading the field.
Rust consumers of harn-builtin-meta must construct BuiltinContract through
BuiltinContract::harness, BuiltinContract::harness_with_effect_authorization,
or the other public constructors. Direct struct literals can no longer create
contracts because the runtime-control classification is checked at the
constructor boundary.