Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

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.

RemovedReplacement
std/agent/governors::governor_pace_decisionstd/agent/cut_rules::pace_cut_rule_decision
std/agent/governors::governor_pace_extend_maxstd/agent/cut_rules::pace_cut_rule_extend_max
std/agent/governors::governor_pace_check_max_injectionsstd/agent/cut_rules::pace_cut_rule_check_max_injections
std/agent/governors::pace_action_ofstd/agent/cut_rules::pace_cut_rule_action_of
GovernorPolicy fields extend_max and pace_check_maxPaceCutRulePolicy fields with the same names

Apply the migration in this order:

  1. Publish the Harn release that contains std/agent/cut_rules.
  2. In one consumer change, update the Harn version, imports, and function names.
  3. Keep budget governor policies separate from PaceCutRulePolicy values.
  4. Run harn check on each updated package before merging that change.
  5. 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 globalReplacement
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#

  1. Replace each removed top-level key using the table below.
  2. Move provider-specific knobs below provider_options.<provider>.
  3. Run harn check <path> to find removed names in literals.
  4. 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#

RemovedReplacement
schema, json_schema, output_schemaoutput: schema or output: {schema, ...}
response_format, output_formatoutput: "json" or output: {schema, ...}
output_validationoutput: {schema, validation: ...}
schema_stream_abortoutput: {schema, stream_abort: ...}
llm_repairrepair
system_preamble, system_prefix, system_contextsystem: [{content, position: "before"}]
system_appendix, system_suffixsystem: [{content, position: "after"}]
system_prompt_partssystem
caps, project_context_profilecapabilities, context_profile
api, roleapi_mode, model_role
prefer, fallback_strategy, strategyroute_policy
model_laddermodels for inline steps; ladder for a catalog name
budget_usdbudget: {max_cost_usd: ...}
reasoning_efforteffort
thinking_policy, problem_scale, task_kind, taskreasoning_policy, reasoning_scale, reasoning_task
hosted_toolsprovider_tools
response_store, responses_storestore
timeout, idle_timeouttimeout_ms, idle_timeout_ms (milliseconds)
fastspeed: "fast"
top-level provider names such as openai or ollamaprovider_options: {openai: {...}}
transcriptopen/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: Kwith_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_loop profiles no longer default to two transient retries. The pre-0.10 profiles injected llm_retries: 2; loops that relied on that implicit resilience must opt in via llm_caller: + with_retry — or build their options with agent_preset(kind, ...), which bakes the equivalent bounded transport retry (with_retry, max_attempts: 3) onto the effective llm_caller: by default (retry: false opts 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 mock provider, whose scripted turns must stay deterministic). It is no longer widened by llm_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_options and agent_sanitize_model_options survive and now live in std/agent/options (same signatures; the audit.kind label changed from agent_stack.model_options to agent_model_options). They are ordinary module exports now, not ambient builtins — add import {agent_model_options} from "std/agent/options".
  • LLM middleware: compose / with_retry / with_logging / with_cache / with_budget from std/llm/handlers on the llm_caller: seam.
  • Tool middleware: with_required_reason, compose_tool_callers, and tools_use_middleware from std/llm/tool_middleware on the tool_caller: seam.
  • agent_stack_model_policy's projection is a pick_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/presetsaudit_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:

ConstantBeforeAfter
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.

RemovedReplacement
DRAFT_PROTOCOL_VERSIONPROTOCOL_VERSION (different value — see above)
rc_name_header_valuestandard_name_header_value
negotiate_rc_http_requestnegotiate_http_request
apply_rc_result_envelope(result, mode, cache)apply_result_envelope(result, cache)
RC_HEADER_PROTOCOL_VERSIONMCP_HEADER_PROTOCOL_VERSION
RC_HEADER_METHODMCP_HEADER_METHOD
RC_HEADER_NAMEMCP_HEADER_NAME
RC_META_KEY_PROTOCOL_VERSIONMCP_META_KEY_PROTOCOL_VERSION
RC_META_KEY_CLIENT_INFOMCP_META_KEY_CLIENT_INFO
RC_META_KEY_CLIENT_CAPABILITIESMCP_META_KEY_CLIENT_CAPABILITIES

Removed outright#

  • McpProtocolMode, with is_modern() and default_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_request rejects any header that is not PROTOCOL_VERSION, and enforce_request_protocol_version rejects any _meta version 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 initialize method. server/discover is the sole lifecycle handshake.

Changed signatures and behavior#

  • enforce_request_protocol_version returns Result<(), JsonValue> rather than Result<Option<McpProtocolMode>, JsonValue>, and it now requires _meta. Every request must carry io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientInfo, and io.modelcontextprotocol/clientCapabilities. A request that omits them is rejected where it previously fell through to a default.
  • McpRequestMetadata is now rmcp::model::RequestMetaObject, so metadata.protocol_version is a method returning Option<ProtocolVersion>, not a Option<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.