One door over two agent runtimes. A host that wants both the Winter Agent SDK and the official
Claude Agent SDK talks to this package as a single SDK: the same query(), the same Options, the
same closed SDKMessage union — plus runtime-selection inputs.
Decision record: WS-00 D19 (2026-09-05). Boundaries that do not move:
@yanlinglabs/winter-agent-sdkstands alone for Winter-only hosts and never learns this package or the official runtime exists.- This package is a selector and an adapter, never a translation layer: Options and the message stream pass through verbatim. It owns runtime selection (the D13 rule), the official-SDK adapter (Options template, spool env, supervised spawn proxy, mirror errors, tool aliases and deny floor, builtin-path containment, Winter MCP plugin registration), shared session-store wiring, the cross-runtime handoff barrier with the materialized-resume decoration doors, and the runtime directory plus cross-runtime messaging router.
- It owns NO TOOL (the user's tool-ownership ruling, R-8-1). The default tools —
SendMessage,ListAgents,ReadNotifications,advisor— are DECLARED once in@yanlinglabs/winter-agent-sdk/toolsand BOUND here, under the official runtime's own built-in names; the capability tools (computer, browser, office) are the HOST's, handed over as MCP servers and forwarded to both legs unchanged. This package re-exports no tool surface of its own. - The host vendors all three packages directly (
winter-runtime-sdk,winter-agent-sdk,claude-agent-sdk); this package declares the two SDKs as peer dependencies and receives their module instances by injection, so a host that never creates a Claude session never loads the official runtime and no SDK is ever instantiated twice. - A
brandprofile flows through unchanged (Winter defaults); Claude Code's own literals stay fixed.
Status: Phase 7b landed all four lanes and routed the door; 0.0.2 was the Phase-8b prerequisite
release. The spine (the package scaffold, the contract re-export, the createRuntimeSdk constructor
with its version matrix, the seams, the test harness and CI) and the four lanes behind those seams —
the official-SDK adapter, the runtime directory and messaging router, the store wiring and handoff
barrier, and runtime selection — are on main, with WS-17's eighteen router-owned rows proven and
cited in docs/conformance-rows.md. See docs/architecture.md for the ownership map, the pinned
interfaces and how this package consumes the Winter SDK.
What 0.0.6 changes (Phase 10b fix wave 2 — three bugs an Opus whole-branch review found in
0.0.5's handoff barrier, plus a re-review's own MAJOR/MINOR/cosmetic micro-round; no peer floor
change):
reviewSwitch reads the LIVE source, not the stale directory record (CRITICAL C1) |
The directory's persisted RuntimeSelection moves only on a cross-runtime handoff; a same-runtime model change or the engine's own fallback/interrupt switch never touches it, so computeSwitchReview's from could silently name a model several turns behind the one that actually produced the session's last reply — under- or over-stating loss, and letting a switch BACK to the stale recorded model slip past the same-profile skip with no prompt. The switch's source is now the live tip's own identity: the last assistant entry's sidecar kind:"origin" record when Winter wrote one, else the official leg's own message.model, else (only when the lineage has no assistant entry at all) the persisted selection. |
the revert takeover retries, and a failed revert is never resumed (MAJOR M1) |
For a winter destination whose confirmInit fails, taking the writer lease back to revert the write-ahead record to the source now retries with the same bounded backoff (5×200ms, HandoffBarrierDeps.leaseRetryDelayMs) step 6 already uses for the identical race the other direction. A revert that still fails is reported HandoffOutcome { kind: "blocked", reason: "revert-pending" } — a host with an exhaustive switch on blocked.reason needs a case for it — never resumed: the old bug's single attempt threw past the outer catch, which read resumed off committed alone regardless of whether the revert landed. The still-owed append is recorded in a new <sessionId>.pending-revert.json file beside the handoff lease, under runtimes/handoff-leases/<projectKey>/ — router-owned, not the host's directory schema, and self-clearing: plan()/any load retries the append once the lease frees up, and deletes the file the instant it lands, AND execute()'s own step 6 reconciles one for the same session (with the writer lease it just took) before staging a brand-new handoff over it — otherwise a later loadEntry could apply the stale note and silently undo a handoff that had since succeeded. A retention or doctor-style pass over runtimes/handoff-leases/ should treat this file as transient, self-healing state, not an error to report. |
reviewSwitch is read-only (MINOR m3) |
reviewSwitch now runs on every setModel, including while a winter child holds the lease. It went through loadEntry, which can append a pendingHandoff:null repair for crash residue; with the lease held elsewhere that append threw, and every model change prompted generically, even Sonnet ↔ Opus. reviewSwitch/computeSwitchReview now read the directory through the bare, repair-free lookup; the repair stays exactly where plan()/execute() already own it. |
| an error-residue or off-catalog tip no longer defeats the same-family skip (MAJOR, re-review) | A failed Claude call writes an isApiErrorMessage:true assistant entry with a synthetic model:"<synthetic>"; read as the live tip, its unknown identity broke the same-family skip right after an error. findLiveTipAssistant now walks PAST that residue (and any isApiErrorMessage:true entry) to the real reply underneath, exactly as the SDK reader already does for W18-13(c). Separately, a REAL but off-catalog id (a dated snapshot like claude-opus-5-20260301, a Bedrock ARN like us.anthropic.claude-opus-5-v1:0) is no longer handed to the review as if it had resolved: new root-adjacent catalogKnowsModel (default-endpoint-resolver.ts) checks ProviderRegistry.resolve() directly rather than inspecting a ContinuityEndpoint's shape (unreliable — a real row and an unresolved fallback can look identical). An id the catalog does not recognise falls back to the persisted selection when it already names the official leg, or keeps family:"claude" intact otherwise. |
a late revert restores only producerRuntime (MINOR, re-review) |
The revert record used to spread the WHOLE step-6 snapshot; applied late (after a retry, or via the pending-revert note), it folded a stale projectionCursor/compatibilityLevel/sourceGenerationCompleted/handoffAt — and a stale health:"clean" that could hide a real repair-required — back over newer values. It now carries only { type, producerRuntime: <source>, pendingHandoff: null }, the same narrow-fold shape unwind()'s own marker-clear already uses. |
| a pending-revert note write failure is logged and named (MINOR, re-review) | writePendingRevert failing used to be silently best-effort, which could reintroduce the original bug through a second path (no note → the next load "repairs" toward the destination). The outcome is still blocked: revert-pending (no new HandoffOutcome shape), but a write failure is now logged once (names and error class only) and folded into the returned detail, so a host can tell "this will self-converge" apart from "this needs manual reconciliation". |
What 0.0.5 changes (Phase 10b — cross-family same-session parity; peer floor:
@yanlinglabs/winter-agent-sdk >=0.0.10 <0.1.0; two NEW required peers, @yanlinglabs/winter-provider-catalog
and @yanlinglabs/winter-provider-runtime, both >=0.0.10 <0.1.0):
| requested-target review (W18-4) | plan(session, to, { requested }) reviews a FRESH selection the caller already decided for a family-crossing model change, instead of the persisted one, unmerged with anything from the source's own credential/provider. HandoffPlan.requested carries it through to confirmInit. |
| the no-credential refusal (W18-3) | SelectionRefusal.reason gains "no-credential" (widened to string beyond the pinned members) and an optional alternatives: SelectionAlternative[] — every catalog door able to serve a Claude row, configured or not (Anthropic API key, Console, Bedrock, Vertex, the claude.ai subscription only when claudeOauthApproved, and any other provider's declared auth view), so a host's hint is built FROM the refusal rather than a hardcoded list. |
| Claude-shape compaction, validated (W18-12) | validateCompaction (step 5) now recognises Claude's own camelCase compactMetadata.preservedMessages.{anchorUuid,uuids} alongside the legacy snake-case shape — officially-compacted sessions could not hand off at all before this. |
| summarized thinking display (W18-17) | Every official launch's extraArgs now carries "thinking-display": "summarized" (merged with appendSystemPromptFile's own entry), asking Claude for its summarized thinking without touching the thinking type or budget the leg derives. |
| the winter-destination write-ahead (W18-5) | For a winter destination, the producer record commits and SharedSessionStore.releaseLease runs BEFORE confirmInit — the daemon's pid writes nothing to the canonical transcript once the winter child holds the lease. A confirm that fails takes the lease back over and reverts the record to the source. winter → official gets a bounded (5×200ms, HandoffBarrierDeps.leaseRetryDelayMs) writer-lease retry, because the exiting winter child's own process exit is asynchronous relative to owner.close() returning. |
| the Claude-ready store and the resume door (W18-8, W18-14) | New official/claude-ready-store.ts's claudeReadyStore(store, deps) wraps sessionStore.load() with toClaudeReady(...) — appends and every other member pass through untouched, and the canonical file is never written by a load. The official leg's own resume-vs-fresh decision now lives HERE, not with the caller: it opens with resume (no sessionId) whenever the canonical transcript already has a conversational entry for the named backend id, and with sessionId (no resume) for a genuinely empty one. |
reviewSwitch (W18-20) |
HandoffBarrier.reviewSwitch(session, requested) — the one pre-flight loss review for every family-crossing change, same-leg or cross-runtime. Reads the canonical transcript and provider-state sidecar through the shared store, resolves both endpoints, and returns @yanlinglabs/winter-provider-runtime's reviewModelSwitch(...). HandoffPlan.review embeds the same classification whenever the plan's selection is servable. |
| the DEFAULT endpoint resolver (fix round 1, CRITICAL) | New root export defaultEndpointResolver(): a registry built from the compiled catalog (@yanlinglabs/winter-provider-catalog's loadCatalog(), no network, no credentials) via createRegistry/createEndpointResolver — never endpointFromOrigin alone, which reported readableState: "none" for every model and silently over-warned a lossless exposed-reasoning transfer. HandoffBarrierDeps.resolveEndpoint/the official leg's own resolveEndpoint fall back to it; a host's own injected resolver always wins. |
| root exports | defaultEndpointResolver, SelectionAlternative (type), hasConversationalEntry/providerStateSidecarPath/readProviderStateSidecar, claudeReadyStore/ClaudeReadyStoreDeps/ProviderStateRecord (from ./official, re-exported), HandoffBarrierDeps.resolveEndpoint/.reviewSwitchBudgetChars/.leaseRetryDelayMs. |
What 0.0.3 changes (P8c-13 — the official-leg HOST SURFACE, so a host can bridge its own
approval broker and materialize MCP servers; no peer floor change):
| root exports | createApprovalBridge/isOurApprovalBridge (+ ApprovalBroker/ApprovalRequest/ApprovalBridgeOptions/DecisionSource/OfficialPermissionMode/OfficialApprovalBridge), materializeOfficialMcpServer/officialMcpServers/winterMcpServerDescriptor/canonicalToolNames/OFFICIAL_MATERIALIZATION_DROPS (+ their descriptor/schema types), minimalOsEnvironmentFrom/buildOfficialChildEnv (+ env-policy types), the containment/auth/options-template types, containmentDispositions/officialDisallowedTools, officialBranchLabel/OFFICIAL_DISCLOSURES, and renderAttributedTurn — all previously reachable only through ./official/index.ts, a test-only import site. buildOfficialOptions uses policy.canUseTool verbatim and assertOptionsInvariants refuses anything not built by createApprovalBridge, so without this a host's every official-leg tool call was denied by the fail-closed default. The spawn-proxy/adapter internals (createSupervisedSpawnProxy, createOfficialAdapter, …) stay OUT — their declaration graph pulls Node-only types into a consumer that never asked for them; the door reaches the adapter through the seam, not through a root import. |
./testing subpath |
createFakeKeychain, withHermeticHomes, withTempDir, createFakeClaudePeer, createFakeWinterPeer, HERMETIC_TRAFFIC_OPT_OUTS, officialCaptureEnv — a NARROW barrel (src/testing/host.ts) that resolves neither @yanlinglabs/winter-conformance nor @yanlinglabs/winter-provider-conformance, so a host writing its own approval-bridge/MCP fixtures does not have to install either. The loopback fakes (anthropicFake, openaiResponsesFake, requestsTo, withLoopbackFake) and the golden-trace tooling stay on the internal, unpublished ./index.ts barrel this repository's own tests use by relative path — a dynamic import() fixes their RUNTIME load without the peer, but not their TYPES, which still name it. |
| root exports (types only) | HandoffParticipants, HandoffSourceOwner, HandoffDestinationRuntime, HandoffResumeTarget, HandoffStepReport, HandoffOwnerHealth, HandoffEligibilityLike, DetailedHandoffOutcome, HandoffBarrierDeps, HandoffSelection, and MaterializedResumeDecoratorHandle (HandoffBarrierDeps.decorator's type) — the data shapes a host actually renders a handoff plan/outcome from, not just HandoffBarrier/HandoffOutcome/HandoffPlan (already reachable via the seams). Pure interfaces: none pulls a node:* specifier into the declaration graph. |
What 0.0.2 changed (peer floor: @yanlinglabs/winter-agent-sdk >=0.0.3 <0.1.0):
peerVersions |
A host DECLARES its peers' versions — the only door inside a compiled binary, where require.resolve cannot see out of the bundle to read a manifest. |
capabilities + toInputShape |
The host's own MCP servers, forwarded to BOTH legs: by reference into the Winter leg's Options.mcpServers, and registered into the official runtime from the same declaration. One declaration, two registrations, identical canonical names. |
advisor |
The standing server carries Winter's four default tools, advisor among them, bound under the official runtime's built-in names (measured: the pin honours an alias key that is not one of its own built-ins — docs/probes/advisor-alias.md). advisor supplies the REVIEWER; the tool is always registered. |
| no tool ownership | src/native-args.ts and the router's messaging handlers are gone: the definitions, schemas, acceptors, handler factories and the advisor all come from @yanlinglabs/winter-agent-sdk/tools, and this package re-exports none of them. |
RuntimeSdk.query() routes by the session's DECIDED RuntimeSelection, and each leg returns its own
runtime's handle untouched.
// The host owns the capability tools and hands them over as MCP SERVERS; the router forwards the same
// servers to BOTH legs and rewrites nothing else (R-8-1). `toInputShape` is the one line of glue the
// official branch needs — its in-process server constructor takes schemas in its own validator's shape,
// and this package deliberately depends on no validator.
const sdk = createRuntimeSdk({
peers,
keychain,
vendoredOfficialRuntime,
capabilities: [computerServer, browserServer, officeServer], // your own `{ type: "sdk", name, tools, instance }`
toInputShape: (schema) => jsonSchemaToZodRawShape(schema), // one line, over the validator you already have
});
// The Winter leg: exactly what it always was. No runtime input, so nothing is decided and nothing
// is stripped — the caller's own `options` object is forwarded by reference (with no `capabilities`
// configured, by IDENTITY; with them, a copy whose every other member is still your own object, plus
// your servers under `mcpServers`).
for await (const message of sdk.query({ prompt: "hello" })) { /* SdkMessage */ }
// The official leg: a `claude-agent` selection, plus what only a host can answer.
const query = sdk.query({
prompt: turns, // string, or an AsyncIterable<string>
options: {
cwd: "/work/repo",
provider: { providerId: "anthropic", authRef: { kind: "keychain", account: "anthropic:default" } },
runtime: {
selection, // the session's PERSISTED choice (D13)
official: {
sessionId: "s-42", // its directory row is `session:s-42`
base: minimalOsEnvironmentFrom(process.env),
// OPTIONAL SINCE 0.0.2: the router materializes the standing server and your capability
// servers itself. This stays as the escape hatch, and its reach is exactly one key: an entry
// under the BRAND's own standing-server name replaces the router's (that key reaches no other
// leg, so overriding it diverges from nothing); an entry naming a forwarded CAPABILITY is a
// typed refusal on BOTH legs, because that name is on both and a silent override would leave
// the two branches running different tools under one canonical name.
// mcpServers: officialMcpServers({ /* … */ }),
},
},
},
});What the host passes, and why the Winter leg needs none of it. A winter-agent session is served
in-process by an SDK that already reads everything it needs from Options. A claude-agent session is
a supervised CHILD PROCESS with a durable row of its own, so the door needs three things Options has
no field for: the session id its directory row is addressed by (WS-14 §6 rule 2's record is written
onto that address, and the messaging registry attaches under it), the minimal OS environment
(WS-14 §3's child env is a REPLACEMENT built from an allowlist — nothing inherits, so nothing is read
from process.env by this package, and a base without HOME is refused, because the runtime
resolves os.homedir() through the OS user database when it is missing and CLAUDE_CONFIG_DIR cannot
scope that), and the vendored runtime path (§5.1: never the user's
installed binary; give it once as createRuntimeSdk({ vendoredOfficialRuntime }) or per query as
options.pathToClaudeCodeExecutable). Everything else has a default that is either derived from the
brand or read from the pinned contract you already fill in: credentials come from
options.provider.authRef through your own KeychainSeam, the spool from the resolved Winter home,
the session store from the one shared instance both branches use.
What SessionKey a door-opened session has. sdk.handoff(session, to) and every store-facing API
take a SessionKey, and both halves of it are chosen by the door rather than by you: projectKey is
the transcript project key the door set on the child (see "the transcript key" below) and sessionId
is the backend uuid the vendor allocated, not runtime.official.sessionId. The door records that
uuid on the session's directory row as backendSessionId the moment the runtime reports it at
system/init, so
sdk.handoff({ projectKey, sessionId: (await sdk.directory.get("session:s-42"))!.backendSessionId! }, "winter-agent")
is the route. WS-15 §6.2's cold resume of an exited official session reads the same field.
The transcript key (R-7b-13). The official child is given
CLAUDE_CODE_PROJECT_DIR_NAME, and it defaults to the Winter SDK's own
transcriptProjectKey(options.cwd) — read off the peer you injected, never re-derived — so both
branches write under one project directory for one working directory, and the auto-memory directory
(WS-14 §2's ONE shared directory) derives from the same key. It is also the projectKey half of the
SessionKey above. The pinned runtime validates that variable against ^[A-Za-z0-9_-]{1,64}$ and
silently substitutes its own cwd-derived name when it does not match, so the door refuses a key it
would reject — including its own default — rather than letting the row, the environment and the memory
directory name a transcript that is somewhere else. Two consequences for a host: a deep working
directory (a sanitized path over 64 characters) needs an explicit short runtime.official.projectKey,
and the vendor's own fallback key is built from the realpath of cwd (/private/var/… on macOS)
while transcriptProjectKey takes the path as given — so pass the key explicitly rather than relying
on either default when the two could differ.
What the persisted selection means. runtime.selection is "what this session's record says", so a
selection that DISAGREES with the record is a request to change runtime — and D13 answers that with
the certified handoff (sdk.handoff(session, to)) or a visible fork, never by serving the new runtime
on the old transcript. The door refuses with RuntimeHandoffRequiredError: in-process on both legs
(pass runtime.sessionId so it can hold you to it) and, on the official leg, against the DURABLE
directory row before a credential is read or a child spawns. On a session with no record yet, the
decided selection is PERSISTED at creation, by the door.
What each leg returns. The Winter peer's Query on one side and the official SDK's own Query on
the other — verbatim, both of them. They are different types (the Winter handle carries messaging
and listModelFamilies; the official one carries a dozen members this package deliberately never
names on its published surface), so query() is overloaded: a call with no options.runtime can
only reach the Winter leg and is typed Query, and a call that passes one is typed
Query | OfficialQuery. isOfficialQuery(handle) narrows it. On the official leg the launch happens
at the FIRST PULL — the same lazy spawn the vendor's own query() performs — because WS-14 §12's
"credentials are fetched at spawn" is asynchronous and query() returns a handle rather than a
promise for one; close() before the first pull starts nothing at all.
A live session's input stream is how messages reach it. R-7b-4: delivery into a live session of
either runtime is a push into that session's input stream. Pass an AsyncIterable<string> prompt and
the door owns that stream — your turns and the router's deliveries interleave in order, with your own
backpressure preserved — and the session is attached to sdk.messaging as a live receiver. Pass a
string prompt and the vendor runs one turn and exits: the session is still recorded in the
directory, but there is nothing to push into, so it is not attached and delivery to it answers
unavailable rather than pretending.
A session's END is recorded too, and it changes what a delivery gets. When the message stream
completes (or close() runs) the door detaches the handle, closes the stream and records the row
exited — unavailable on a stream that ended in a fault. So a streaming session whose input has
ended answers unavailable (non-retryable) exactly like a string-prompted one, never
delivery_uncertain: "the write may have landed" is not an honest answer for a session where nothing
can land. sdk.messaging.listReachable stops listing a session at the same moment (WS-10 §10.2: a
listing does not enumerate exited transcripts), and a launch that refuses synchronously leaves no row
at all.
The official branch disables the runtime's remote feature configuration by default (R-7b-11).
Every official child gets TRAFFIC_OPT_OUT_VARIABLES — the four names are exported, so read them
rather than trusting this sentence. Measured on the pin, same binary and same options: 25 advertised
tools with the fetch, 21 without; DesignSync, Monitor, PushNotification and Anthropic's own
API-side advisor_20260301:advisor appear only when a CDN answers — that one is the vendor's server
tool, orthogonal to what follows. A tool surface that moves with no version moving is not a pinned
artifact, so this is on unless you say otherwise: remoteConfig: "allow" (per query on
runtime.official, or deployment-wide on createRuntimeSdk({ official: { env: { … } } })) opts back
in, and the choice is recorded on the session's directory row as RuntimeDirectoryEntry.remoteConfig.
Winter's own advisor is registered on the official branch too (R-8-1), backing Anthropic's rather
than being refused. WS-14 §11's standing MCP server used to throw if a capability list named
advisor — each branch was meant to have its own, unrelated advisor. The user's tool-ownership ruling
reverses that: mcp__<brand>__advisor is reachable on the official branch exactly like
send_message/list_agents, independent of whether the CDN-gated API-side one above is present that
session. docs/probes/d29-advisor.md §6 has the full reversal and what it does and does not change.
The materialized-resume PREFERRED door is open for the pinned runtime, by measurement (R-7b-12).
WS-17 §8's four probes pass against 0.3.250 on darwin-arm64 and linux-x64, so a handle over that peer
decorates the materialized copy and leaves the canonical file byte-pure; any other version — or no
official peer — gets the always-available FALLBACK door (one labelled entry appended after the
destination confirms). The verdict is data (materializedResumeReportForPin), keyed by version and
re-derived in CI against the real artifact, so a pin bump is a reviewed event rather than an inherited
answer. A host that measured its own pin passes handoff: { decorationReport } and wins.
Each of these is behaviour you cannot discover from the type signatures, and each has a test behind it. They are stated here because a host that learns them from an incident learns them expensively.
Inbound messaging to an official session is FAIL-CLOSED until you wire a permission class. WS-10
§13 decides delivery from the sender's and receiver's permission classes, and the official runtime
exposes no way to read its own. So official.permissionClass — passed as
createRuntimeSdk({ messaging: { messaging: { official: { permissionClass } } } }) — is the ONLY way
that class is ever known, and without it every message to every official session is HELD, never
delivered. The hold is default-kind: it is released the moment the class becomes knowable, and it
expires under §13's five-minute dialog window. Two things follow that you should design for: the
expiry sweep is LAZY — a held message's receipt is rewritten to refused when something next
addresses that receiver, not on a timer of its own, so between the fifth minute and the next send the
receipt still reads held; and a hook that throws is an answer, not a crash — it falls through to
unknown, which holds.
Session-name leases are global by construction. A display name that has been used is remembered
after the object is gone, so addressing it earns "that referred to something that has gone" rather
than "no such agent" (WS-10 §11 rule 5). Rule 5 governs children within their owning conversation;
sessions are global. Nothing forgets by itself: RuntimeDirectoryOptions.retention is where you set
how long a released lease and a receipted delivery record survive, and absent means forever,
because forgetting a lease changes what a model is told about a name it can no longer reach.
The containment floor is a scan, not a sandbox. The pre-hoc permission floor refuses any call
whose ARGUMENTS name a forbidden target (CLAUDE.md, .claude/, ~/.claude/plans), case-folded and
NFKC-normalized for path fields and quote-stripped for command text. Shell-escape and constructed
name spellings are caught POST-HOC, by a sweep registered on PostToolUse, PostToolUseFailure
and PostToolBatch that snapshots the forbidden names under the session's cwd and the child's HOME,
removes what APPEARED under its roots during the call, records a typed breach and ends the turn — its
diff is TIME-BASED rather than causal, so under the child's HOME a vendor home created by something
else during a long call is removed and attributed to that call (narrow: an existing one is in every
baseline and is never touched). The sweep walks both
roots to a bounded depth (6 by default) around every filesystem-touching call, so it costs a walk
per call: on a large tree that is the dominant cost of the floor, and an incremental/fs-events
design is the follow-up. It sees the synchronously-visible effects of the call it brackets; a
background write that lands later is caught opportunistically by the next swept call.
A host PreToolUse hook that answers allow makes 0.3.250 skip canUseTool for that call. The
floor runs first and any deny wins, so containment is unaffected — but your broker will not see that
call, which matters if you were counting on it for audit.
The extras door is a positive allowlist with two closed escape hatches. configuredExtras admits
only names the pinned artifact's own environment registry declares AND that an independent rule
classifies as non-credential; anything auth-shaped is refused with a sentence naming why, and a
deployment that has REVIEWED a specific credential-shaped variable names it in
reviewedCredentialShapedExtras — one name at a time, never a wildcard.
A second class is refused by name, and the set is exported so you can read it rather than trust a
description: EXECUTION_INDIRECTION_ENV_NAMES and EXECUTION_INDIRECTION_ENV_PREFIXES
(src/official/). It is the pinned artifact's own scrub list — the environment the runtime strips
before running its policy helper, so the definition of "changes how the child executes code" is the
vendor's rather than ours — plus that runtime's own doors: CLAUDE_CODE_SHELL (the Bash tool's
shell), CLAUDE_ENV_FILE (sourced into every Bash call), the settings paths and plugin directories
(settings carry hooks, apiKeyHelper and env; plugins are code), the package-manager config files,
and the binary paths it executes. Whole prefixes are refused where a closed list cannot work:
LD_*, DYLD_*, BASH_FUNC_*, PYTHON*, PERL5*, RUBY*, LUA_*, DOTNET_*, COR*,
APPDOMAIN_MANAGER_* and all of GIT_* (git reads GIT_CONFIG_* for a credential.helper and
runs GIT_SSH_COMMAND, GIT_EXTERNAL_DIFF and GIT_ASKPASS).
Neither of the other two rules can see this class: these names are not credential-SHAPED, and the
pinned registry legitimately declares many of them, because the runtime really does read them — which
is why "the registry declares it" cannot be the whole test. Two of them were measured on the pin doing
exactly what the class describes before they were refused (a planted CLAUDE_CODE_SHELL ran as the
Bash tool's shell 114 times in one session; a planted CLAUDE_ENV_FILE was sourced into every Bash
call — BASH_ENV by another door). The reviewed door for this class is reviewedExecutionExtras,
again one name at a time, and a drift gate fails the suite when a pin bump adds a registry name of
this shape that nothing has classified.
The approval bridge is fail-closed when no broker is configured. A host MUST supply a broker: with none, every call that reaches the bridge is denied. That is deliberate — the alternative is a session that approves its own tool calls — and it means "I did not wire a broker yet" behaves like "deny", not like "allow".
interrupt() stops the turn. On a streaming turn it ends the generation by throwing, which is
what a projector (Phase 8) must expect: an interrupted session's stream terminates with an error
rather than a result.
A handoff will not check that the destination can serve the session unless you give it a catalog.
HandoffPlan.selection reports unreviewed by default, because only the host holds the model catalog
and the credential map. Passing createRuntimeSdk({ handoff: { selectionInputFor } }) turns it on:
plan() then asks the selector whether the RECORDED row is still servable and whether the destination
branch can serve it, and a plan that cannot be served carries a typed refusal instead — execute()
offers the lossy fork before the lease is taken, rather than after the drain and the staged copy.
A handoff can deliberately leak one staging directory. If the destination confirms init and the
producer record then fails to write, or if the destination throws while starting against the copy it
was handed, the claude-resume-<uuid> staging root SURVIVES — the destination may be reading it, and
deleting a live child's CLAUDE_CONFIG_DIR is worse than leaving a directory behind. It is locatable
at outcome.target.stagingRoot and belongs to your retention pass.
Compiled hosts must declare their peers' versions. The version matrix's second probe
(resolved-manifest) resolves a peer's package.json by walking up from createRequire(...).resolve()
— which cannot see outside a compiled binary's own bundle (file:///$bunfs/...). A host that
self-spawns its own compiled artifact and whose injected peer exports no version identity of its own
has nothing left for the matrix to read, and construction refuses. createRuntimeSdk({ peerVersions: { winterAgentSdk, claudeAgentSdk } }) is the door: supply both from your own vendored
VERSIONS.json, stamped at your own build time (WS-02 §7.1). A declared version is checked FIRST —
it wins even over a peer that exports its own identity — and still has to satisfy the same
range/exact-pin checks as either probe; it changes how the identity was discovered, not what counts
as supported. A host that runs uncompiled (plain bun/node, source or an ordinary install) never
needs this field.
"Exactly one runtime owns a session" is a convention here, not a mechanism. The barrier moves ownership only after the destination confirms, and the transcript's producer record is authoritative — but the store's writer lease is re-entrant per pid and this router hosts both branches in one process, so a determined host can still write from the source after a handoff. A real guard needs a writer-lease generation the SDK store does not have; it is on the SDK's list.