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.

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

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.