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

Website: https://harnlang.com/migrations/v0.10.html

This page documents Harn, which is pre-1.0. Language, standard library, and CLI APIs may change. If the intended version is unclear, clarify before using this page.

---

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:

```harn,ignore
const registry = tool_registry_from(specs, info, components)
```

with named fields:

```harn,ignore
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:

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](#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:

```harn,ignore
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:

```harn,ignore
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:

```bash
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:

```harn,ignore
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:

```harn,ignore
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:

```harn,ignore
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:

```bash
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:

```harn
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.

[#6148]: https://github.com/burin-labs/harn/issues/6148
[#6149]: https://github.com/burin-labs/harn/issues/6149
[#6161]: https://github.com/burin-labs/harn/pull/6161

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

```harn
// 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](../llm/llm_call.md#options-dict).

### Check portable options against the selected route

`top_logprobs` was removed. Put the alternative count in the value that
enables log probabilities:

```harn
// 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)

```harn,ignore
const r = harness.llm.call(
  "prompt", nil, {llm_retries: 2, llm_backoff_ms: 500},
)
```

### After (0.10)

```harn,ignore
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:

```harn,ignore
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:

```harn,ignore
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:

```harn,ignore
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)

```harn,ignore
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)

```harn,ignore
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)

```harn,ignore
import {audit_agent} from "std/agent/presets"

const result = audit_agent("Audit the release", {tools: tools})
```

### After (0.10)

```harn,ignore
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:

```harn,ignore
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](../sessions.md) are the sole surface (see
[Migrating from 0.6.x to 0.7.0](./v0.7.md)). 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`, 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:

```rust,ignore
// 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>`:

```harn
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.

## `std/json.pick` is now the global `pick`

`pick` is a global builtin. It no longer lives in `std/json`, so an
`import { pick } from "std/json"` fails to check. Remove `pick` from the
import and call it directly. Replace `json.pick(data, keys)` with
`pick(data, keys)`.

Two behaviors changed:

- The old helper dropped `nil` values. The builtin keeps them. To drop them,
  call `pick_keys(data, keys, {drop_nil: true})` from `std/collections`.
- The old helper returned `{}` for a source that wasn't a dictionary. The
  builtin raises a runtime error, and the checker rejects a source it can see
  is wrong.

The result also carries the picked fields' types instead of a single
dictionary type. See [Pick fields from a record](../pick.md).

### Before

```harn,ignore
import { pick } from "std/json"

const config = pick(settings, ["model", "temperature"])
```

### After

```harn,ignore
const config = pick(settings, ["model", "temperature"])
```

---

## Read next

- [0.6.x → 0.7.0](https://harnlang.com/migrations/v0.7.md)
- [`const`/`let` keyword scheme](https://harnlang.com/migrations/const-let.md)
