Help us shape the future of Go! Take the 2026 Go Developer Survey

host

package module
v0.18.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: Apache-2.0 Imports: 23 Imported by: 2

README

looprig/host

host is Looprig's Department runtime host. A Host is a process that keeps runtime targets resident, consumes commands addressed to it, serves the HostLink realtime surface, and drains on release.

Host is consumed by Factory. It imports no Factory package, no web or terminal UI, and no product repository. That boundary is enforced by import_boundary_test.go, which parses the import declarations of every Go file the module owns rather than searching source text, and fails loudly if it walked nothing.

A nested module or repository under host/ would fall outside that walk by construction, so nestedModuleAllowlist is empty and a separate assertion fails on any undeclared one: a silent hole is converted into a deliberate exemption.

Centrifuge is permitted in exactly one directory, internal/realtime/hostlink/, and the scope is derived from a single constant and compared segment by segment, so a sibling package under internal/realtime/ does not inherit it.

Install

go get github.com/looprig/host@latest

A Host is composed by a product: cmd/host is generic, registers no agents and refuses to start without product collaborators (launch targets, runtime adapter, credential verifier, storage). See "Deployment and operations".

Packages

Package Purpose
github.com/looprig/host Options/New, Composition/Compose (a running Service), Run (HostLink plus /readyz, /healthz, /metrics on one listener), PublicJournal/NewPublicJournals
github.com/looprig/host/department The immutable set of launch targets a Host serves and the runtime capabilities it consumes (LeaseEpochReporter, AttemptCloser, ...), plus the Capabilities.Recovery declaration
github.com/looprig/host/department/departmenttest RunRuntimeConformance: launches one session through any LaunchTarget and checks the contract Host relies on
github.com/looprig/host/harnessruntime The Host runtime backed by harness: Target, Registration, SharedRig/RigPerTenant/RigsFunc, WithBlockDecoder/DecodeInputBlocks
github.com/looprig/host/cmd/host The generic executable; reads HOST_* environment variables and needs a product bootstrap
examples/deploy A pooled Kubernetes example (pooled-cloud.yaml), checked by its test

Everything else is under internal/: residency, command consumption and settlement, gate projection, HostLink (internal/realtime/hostlink), and the SessionStore adapter.

Where it sits

Tier 5 (orchestration) of the Looprig workspace. Direct Looprig dependencies: core, harness, inference, sessionstore and storage. Factory consumes Host over HostLink; Host imports no Factory package.

Upgrading to v0.18.0

Host now pins centrifuge v0.39.3, protocol v0.22.1 and the test-only centrifuge-go client v0.12.1. The transport update fixes the metrics aggregator goroutine leak on shutdown and includes the GHSA-4r3x-2rwr-6w65 decoder fix. Wire version 1 is unchanged; mixed Host fleets interoperate.

SessionStore is pinned at v0.15.0. PublicJournals forwards reads to that store, giving Host compositions the TipOnly request and the object-backed public-body reader supporting up to 16 MiB. Callers needing only the journal tip should use TipOnly. Upgrade Factory readers to sessionstore v0.15.0 before serving offloaded public bodies above 512 KiB; older readers fail the entire page. No durable format changes. Harness remains pinned at v0.45.0.

Status

Department, residency, HostLink, warm release and drain are built. Warm release takes effect from v0.7.0: a pooled Host composed with host.Compose releases a session that has been whole-session idle for Options.WarmTTL — never one with a gate open, a turn in flight or durable command work outstanding — and the next command re-places and restores it. Before v0.7.0 no composed Host ever warm-released. When the countdown fires the Host first halts the session's command consumer, waiting out a pass in flight (a command admitted from then on is left pending for the next Host), then re-confirms the session is still idle and has done no work since the countdown was armed, then re-reads the durable inbox. The runtime release is bounded by Drain.Grace. A runtime that does not release in time — a turn that started inside the release window — is never torn down under that turn: the release is taken back, and the session is an ordinary resident, admitting session again under the same grant and generation (Factory can bind to it, wake it and answer its gates), and the next idle countdown tries again. Only if that revert is refused (the Host is draining, or the grant is gone) is the session held for the drain. Both are counted in host_release_failures_total.

A dedicated Host does not warm-release — a deliberate departure from runbook O6.2 ("the same release behavior as pooled"). A warm-released dedicated session would leave its Pod running with nothing resident while placement, the controller's desire and the Pod all still name it, and no released Factory or controller has been proven against that state. Its session ends through the controller's drain-before-delete, as before. internal/sessionstoreadapter binds them to the released github.com/looprig/sessionstore store (currently pinned at v0.15.0), and harnessruntime to github.com/looprig/harness (currently v0.45.0). Core is v0.13.1. Check go.mod for the exact pins of a given release.

Since host v0.7.1 Host requires harness ≥ v0.37.1, which is v0.37.0 plus one further fix, and both matter to a pooled fleet.

v0.37.0 closes a crash window where an admitted input could settle applied with its effect lost, or a graceful shutdown could cancel an input the store already recorded applied: see harness's own v0.37.0 release notes for the mechanism (Admission, replay-from-intent on restore). It requires no Host code change and is not a one-way upgrade — a v0.36.0 runtime still opens a v0.37.0 journal, and a v0.37.0 runtime replays input debt a crashed v0.36.0 runtime owed. Do not mix harness v0.36.0 and v0.37.0 (or v0.37.1) runtimes over one Host pool: only a v0.37.0-or-later runtime performs the replay-on-restore that makes the fix effective, so a pool serving the same sessions from both leaves the outcome dependent on which runtime happens to restore a given session.

v0.37.1 lifts the obligation that every pooled Host must mount the workspace base at the same path. Before it, restoring a session onto a Host whose per-session workspace base differed from the one it was created under (a pod-specific mount path, a rollout that changed the mount, pooled and dedicated Pods with different paths, a symlink resolving differently per node) was refused — "restore rejected by policy: 1 warn category (workspace)" — and the session was silently never re-placed. Restore now compares placements by mode alone; a placement-mode change, a different exclusive or shared fixed root, a placement added or removed, and a caller-supplied root still warn. The written fingerprint is byte-identical to v0.37.0, so this is also not a one-way upgrade by itself — but a fleet that relocates workspace bases across Hosts needs v0.37.1 on every Host that may restore one of its sessions; a v0.37.0 Host still refuses a relocated base.

A composed Host applies a command end to end: it attaches a disposition session, consumes its durable command stream, claims a command under its residency grant, authorizes exactly one dispatch attempt, drives the runtime with that attempt's identity and settles the command from the runtime's own durable disposition. Read the next section before you plan around it; the mechanism is measured, not cautionary.

Since v0.5.0 a session's FIRST command settles — see "Upgrading to v0.5.0: create and restore". Since v0.4.0 every gate an agent raises reaches Factory, and the user's answer settles through the same path — see "Upgrading to v0.4.0: gates".

Deployment and operations

A product supplies the Department's launch targets; a harness-backed product builds each with harnessruntime.Target rather than writing a runtime adapter (see "Upgrading to v0.16.0"). The advertised isolation class, placement and capacity describe what Factory may select; they do not make a runtime compatible with a target. Compose the product's registered target and verify the runtime adapter before publishing it. A target declares an opaque runtime compatibility ID; restore refuses a journal created under a different ID, so preserve that ID only for builds that can actually read the same state. The generic cmd/host has an unconfigured bootstrap and refuses to start without product collaborators.

host.Run serves HostLink, /readyz, /healthz and /metrics on one HTTP listener (HOST_LISTEN_ADDRESS in cmd/host). HostLink is internal: keep the whole listener on an internal network, including its probe and metrics paths. HostLink verifies the tenant credential through the product's authenticator. It is service-to-service and does not use browser Origin or CSRF checks. host.Run uses plain HTTP; terminate TLS in front of the listener or supply a secured listener in the product composition. The Host advertises a bare internal endpoint; Factory v0.5.0 or later derives each tenant's HostLink path. Older Factory versions dial the base verbatim and get 404. Roll Factory and Host as a paired change when switching to the bare base.

Pooled means several sessions can reside on a Host; dedicated means one fixed session (HOST_FIXED_SESSION_ID). Neither word describes durability. Recovery requires a shared SessionStore backend, its leases and the Harness journal; object bytes need a separately selected object store. Legacy SessionStore PutObject and object-first retention are not a Host disposition SessionObjectStore. A pooled Host's whole-process drain runs on shutdown; HostLink drain is available only for a dedicated resident fixed session. The dedicated workload controller must observe the terminal drain status before it deletes that workload.

Run drains before shutting down the listener, so the drain-status observation remains readable. Set HOST_DRAIN_GRACE inside the platform's termination grace with margin; HOST_DRAIN_IDLE_BOUNDARY and HOST_DRAIN_PUBLISH_BOUND bound parts of that work. /readyz reports admission readiness and /healthz reports process liveness. Releasing a gated runtime gracefully is unsupported: since v0.17.0 the drain abandons it crash-equivalently (the gate stays open, the journal lease is released), so a process may keep running after Stop and even restore the session again — see "Upgrading to v0.17.0". A runtime the drain could not abandon is reported in DrainReport.Parked; a process with any parked session must exit.

The isolated /metrics registry exports host_sessions (by state), host_sessions_gate_waiting, host_sessions_command_blocked, host_admission_weight_consumed, host_admission_weight_capacity, host_command_lag, host_command_queue_depth, host_release_failures_total and, only when known, host_memory_budget_bytes. Capacity and queue gauges are local Host state; Factory backlog, resident wait, reconciliation and controller drain metrics are not exported here. Watch host_sessions_command_blocked: a nonzero value means a durable command cannot progress.

A Host advertises hostlink.command.gate_response only when its gate seams are composed. Factory must check that capability before admitting an answer. Keep every Factory gate reader on sessionstore v0.12.0 or later before a Host publishes a gate. Once a Host has applied a gate response, do not roll it back below v0.4.0; after a create or restore disposition, do not roll it back below v0.5.0. A cold AskUser answer/resume remains unsupported.

Upgrading to v0.17.0: a drain abandons a gated runtime instead of parking it

Before v0.17.0, Service.Stop with an agent awaiting a gate left that runtime PARKED — running, uncancelled, holding its harness journal lease until the process exited. harness refuses a graceful release of a session that is not whole-session idle, and a session at a gate never is. That was safe for cmd/host, which exits after its drain, and wrong for any embedder that keeps running: Stop closed the session store beneath a live runtime, the runtime's gate timers kept running, and a Host restarted in the same process could never restore the session (lease held).

Now the drain abandons it. After the refused release, and BEFORE the residency is tombstoned and its lease handed back, the drain calls the runtime's crash-equivalent department.PersistenceFaults.AbandonResidency — the path Host already used for a faulted runtime — and waits for the runtime to tear down, bounded by Drain.IdleBoundary. harness seals every durable write first, so nothing is journaled: no TurnInterrupted, no GateResolved{abandoned}. The gate stays open in the journal and the projection, the journal lease is released, and a successor — in this process or another — restores the session with the gate open and answerable. (Plain context cancellation would resolve the gate; this does not cancel.)

DrainReport gains Abandoned and Parked ([]host.DrainSession) — additive API, hence a minor. A session is Abandoned only when the abandon returned no error and the runtime's liveness ended. It is Parked when its runtime offers no department.PersistenceFaults (so cannot be abandoned), or the abandon returned ANY error or exceeded its bound (also recorded as an abandon_residency failure): a failed abandon proves nothing about the journal lease, so it is never reported Abandoned. An abandon that overruns its bound keeps running in the background; Stop does not wait for it. Parked is a snapshot at report time: a teardown that later succeeds still has its session context cancelled, and the late success is logged at INFO. Requires harness ≥ v0.45.0 (pinned): harness v0.44.0's teardown logged and swallowed a failed journal-lease release, so a session could be reported Abandoned with its lease still held. v0.45.0 returns *session.LeaseReleaseError from ReleaseResidency and AbandonResidency, and harnessruntime joins department.ErrResidencyStillHeld to it (harness's consumer obligation: the residency is still held). On EITHER path — a gated session's abandon or an idle session's ordinary graceful release — such a session is reported Parked (never Abandoned, and never abandoned on top of the torn-down runtime), the failure carries the LeaseReleaseError (errors.As), and the drain is not drained: no pinned backend expires a lease, so a successor stays locked out until an operator frees it. A persistence-fault or lost-grant give-up whose abandon keeps its lease goes on the release ledger, which withholds drained on every later drain. A product runtime outside harnessruntime should join department.ErrResidencyStillHeld to the same condition.

Size the platform's termination grace above Drain.Grace + 2 × Drain.IdleBoundary (HOST_DRAIN_GRACE, HOST_DRAIN_IDLE_BOUNDARY). A gated session's refused release spends the whole grace; its abandon then gets a fresh budget of one idle boundary, and one more if it ignores cancellation. A parked runtime behaves as before v0.17.0, and Stop still skips the process-wide context cancellation while any is parked. An embedder that keeps running must treat a non-empty Parked as a leak. Every harnessruntime.Target runtime can be abandoned.

This is the default for every drain, cmd/host included, with no option. It is safe there because abandonment is exactly what cmd/host's exit already did to a parked runtime, minus the parts that were hazards: the runtime stops now rather than at exit (its gate timers can no longer resolve a gate against a stopped publisher), and its journal lease is released instead of left to lapse, so a successor on another Host restores sooner. A HostLink-initiated drain of a dedicated session takes the same path. No wire, journal or store format change.

Upgrading to v0.16.0: harnessruntime, and Host refuses a runtime that cannot recover

BEHAVIOUR CHANGE — A COMPOSITION THAT NEVER DECLARED RECOVERY NO LONGER COMPOSES. host.Composition.RuntimeProfile is new and its zero value is host.RuntimeProfileDurable. Under it, host.Compose refuses — with *host.InvalidCompositionError{Field: "Collaborators.Registrar"}, naming each agent and what it lacks — any registration whose target does not declare department.Capabilities.Recovery{AttemptCloser: true, PersistenceFaults: true}. Every target built before v0.16.0 declares neither, so every existing composition fails Compose on the version bump alone until it declares or opts out. This is intended: without AttemptCloser an ordinary failover with an attempt in flight wedges a session's whole command stream, and without PersistenceFaults a journal fault leaves a session resident and refusing every command — both compile, pass every probe, and fail only in production.

The declaration is verified, not trusted: a target built with department.NewRigTarget refuses at launch (with the session released) a runtime that lacks a capability its target declared — *department.IncapableRuntimeError with Missing: ["declared AttemptCloser absent"] or ["declared PersistenceFaults absent"]. host.RuntimeProfileBestEffort is the explicit opt-out, for fakes and experiments; it admits undeclared targets and logs one WARN on Start.

github.com/looprig/host/harnessruntime is the adapter Host has always used, now public (it moved from internal/harnessadapter unchanged). A harness-backed product no longer hand-writes a runtime adapter:

target, err := harnessruntime.Target(
    harnessruntime.RigPerTenant(map[sessionwire.TenantID]*rig.Rig{"acme": acmeRig}),
    compatibilityID, // product-owned department.CompatibilityID
    department.Capabilities{SupportsPooled: true, AdmissionWeight: 1, CaptureSafety: department.CaptureSafetyStreaming},
)
// Registrar: []department.Registration{harnessruntime.Registration("assistant", target)}

Target declares both recovery capabilities and holds the declaration true at every bind: it requires harness's PersistenceFaultReporter and ResidencyAbandoner with a non-nil fault channel, and a runtime-command applier that is a runtimecommand.AttemptCloser, refusing any session that lacks one. Every refused launch — in harnessruntime or in department — is released under a context detached from the launch's, and a failed release is joined to the refusal. A bound session reports AttemptCloserAvailable/PersistenceFaultsAvailable, so a product declaring Recovery itself on NewRigTarget(harnessruntime.New(...)) is held to the same truth. It also forwards LiveOptionsSubscriber (text, reasoning, tool steps), copies Principal on every kind and Metadata on create and input, re-presents a create's first message, decodes gate_response, refuses an unresolved PayloadRef, launches under the binding's RigSessionID, and exposes EphemeralDrops(). Resolvers: SharedRig (one rig), RigPerTenant (refuses an unknown tenant with ErrUnknownTenant), RigsFunc (anything else: per-launch workspaces, journal stores). The body decoder defaults to DecodeInputBlocks — Core's strict InputRequest, the body Factory admits; name one with WithBlockDecoder only when your bodies are not Core's.

departmenttest.RunRuntimeConformance(t, target, launch) launches one real session through any target and checks: recovery declared; RigSessionID honoured; the required capabilities, a held lease epoch, and both recovery capabilities actually present (AttemptCloserAvailable, PersistenceFaultsAvailable); LiveOptionsSubscriber; principal and metadata reaching the runtime's own applier (through a product-supplied Recorder); an unresolved PayloadRef refused. Host runs it against harnessruntime over a recording applier and over a real rig.

Migration — what breaks on the bump, and the fix:

  • Carbon (internal/app/department.go): host.Compose refuses Carbon's target (carbonCapabilities() declares no Recovery). Minimal fix: add Recovery: department.Recovery{AttemptCloser: true, PersistenceFaults: true} to carbonCapabilities() (Carbon's runtime already implements both). Intended fix: replace carbonRig/carbonRuntime and the hand-copied decode, gate and live-subscription code with harnessruntime.Target(harnessruntime.RigsFunc(create, restore), compatibility, carbonCapabilities()), keeping CarbonAgentID, CarbonCompatibilityID, LaunchScope and SessionLauncher; DroppedLivePreviews becomes the bound session's EphemeralDrops; Carbon's MissingCommittedPublicationError / PersistenceFaultsMissingError are replaced by harnessruntime.IncapableSessionError. Run RunRuntimeConformance against the result.
  • tests (internal/orchestrationtest): composedhost.go and pooled.go build targets with KitCapabilities(), which declares no Recovery, so every composed-Host lane fails Compose. Add Recovery to KitCapabilities() where the runtime really implements both (pooledSession must then offer AttemptCloser and PersistenceFaults, or launches are refused); for a fake runtime that implements neither, set RuntimeProfile: host.RuntimeProfileBestEffort on that blueprint. Keep pooledSession only where a test asserts on what crossed the seam; lanes that just need a harness runtime move to harnessruntime.Target.
  • Starters (browser-app/app.go): host.Compose refuses the hand-written hostRig's target. Delete runtime.go and build the target with harnessruntime.Target(harnessruntime.RigPerTenant(map[sessionwire.TenantID]*rig.Rig{cfg.Tenant: agentRig}), compatibilityID, caps).
  • Any other composition that calls department.NewRigTarget or implements department.LaunchTarget itself: declare Capabilities.Recovery (only if the runtime implements department.AttemptCloser and department.PersistenceFaults — a false declaration is refused at the first launch) or set RuntimeProfileBestEffort. cmd/host runs under the durable default, so a product Bootstrap's Registrar must return declared targets.
  • harnessruntime.Option is func(*Adapter) error (it was func(*Adapter) internally); WithBlockDecoder(nil) is refused with ErrNilOption.

No wire, journal or store format changes; nothing here is one-way. Pins harness v0.44.0 (additive; no Host-visible change).

Command principal and message metadata (v0.11.0)

Host unconditionally advertises hostlink.attribution.principal. Every command kind's stored Core body is strictly decoded after the claim and before the dispatch attempt. A permanently malformed body is rejected. An unknown_field or unsupported_version stays claimed without an attempt (unreadable_command) for a newer Host or Factory's deadline sweep. A gate response with a future member now blocks as unreadable_gate_response; Hosts through v0.10.3 rejected it. A valid gate response on a composition without a gate reader still reports no_gate_reader.

The strictly decoded principal crosses to department.RuntimeCommand on all five kinds, and metadata crosses on create and input. These are recorded assertions, not authorization evidence. A product runtime implementing department.CommandApplier should forward both to runtimecommand.Admitted. harnessruntime (public since v0.16.0) does this and hands input bytes unchanged to the BlockDecoder; create's first message is re-presented in input shape with its members. A decoder using Core's strict InputRequest must use core v0.12.0 or later to accept stamped commands.

This is a one-way upgrade. Once a journal holds a stamped or presented record, do not roll a Host back below v0.11.0 / harness v0.41.0: the older reader silently loses these members. Once a store holds a v3 inbox row, every Host must remain on sessionstore v0.14.0 or later. A body over 64 KiB is stored by reference; a Host advertising hostlink.payload.reference reads and verifies it before applying the command.

Upgrading to v0.10.0: no runtime identity reaches a client

Finding W1. A harness public event body names the runtime session id (session_id, also under cause, and in some error text) and, on an event a command caused, the runtime command id (cause.command_id). Both are private: the runtime session id is the durable binding's, and the runtime command id is the mapping Factory's store keeps beside the command a client admitted. Host relayed those bodies verbatim, and Factory serves them to browsers on the live tail and from /journal.

v0.10.0 rewrites them, and nothing else:

  • Runtime session id → public session id, everywhere. This covers every session_id value at any depth, and any mention inside a string: a TurnFailed/RestoreErrored message, a capture-spill path.
  • cause.command_id → the public command id the client admitted. Any other mention of that same runtime command id is replaced too.
  • Commands with no public id. A machine-originated cause, such as a subagent hand-back, is removed from cause, and a cause left empty is removed with it.

Member order and every other byte are kept, so the body is still canonical. The cost is linear in the body whatever its nesting. Loop, turn and step ids stay; they are the runtime's own content identities. Projected bodies are not harness events any more: a public command id need not be a UUID, and a reply to a machine command loses its cause. Do not decode them with harness.

The live tail is projected by Host with no action needed.

  • Command mapping: read from the session's disposition inbox.
  • Mapping read fails: the publication is retried with bounded backoff (WARN per attempt) until the session's work stops. The tail does not end.
  • Publication genuinely refused (a body that is not canonical, or a transport refusal): it is logged at ERROR with its cause. The session's routes are invalidated, so every Factory replica resets its viewers from the durable journal, a new tail is subscribed from the live tip, and the refused subscription is stopped first, before the new one opens.
  • A mapping outage longer than the adapter's 256-event buffer still ends the live tail — as Lost, not Refused, because the buffer overflows and the channel closes underneath the retry (the pre-existing slow-consumer policy). A Lost tail is deliberately not restarted. Viewers repair from the durable journal, same as any other Lost ending.
  • Stop during a read: a stop, with no route invalidation.
  • Runtimes with no runtime session id (not launched through department's rig adapter): relayed unprojected, with a WARN at attach. That is the one place W1 is not enforced.

/journal needs factory ≥ v0.10.0. Factory reads a Host session's journal directly through its resolver, never through Host. So a composition must wrap the reader it returns in host.PublicJournal. That type applies the same function, taking the mapping from the journal's own application prefixes, so a live body and a durable body are byte-identical. The wrapper needs the public session id, which only factory v0.10.0's WithSessionJournalResolver hands a resolver:

journals := host.NewPublicJournals(0) // one per process; keeps each session's mapping
factory.WithSessionJournalResolver(func(ctx context.Context, tenant sessionwire.TenantID, session sessionwire.SessionID, binding sessionstore.SessionBinding) (factory.JournalReader, error) {
	store := runtimeJournalFor(tenant, binding) // sessionstore.Open(..., WithLegacySingleTenant(tenant))
	return journals.Reader(store, tenant, session, binding)
})

A kept mapping reads through the reader handed most recently for its session. A PublicJournal refuses (ErrPublicJournalScope) any read that is not for the bound tenant and the binding's runtime session. Its errors never quote the runtime id. With factory ≤ v0.9.0 /journal still serves runtime ids.

Consumers that correlated on runtime ids must switch. Any consumer matching cause.command_id against a runtime command id, or session_id against the binding's runtime session id, must use the public ids.

Also fixed: R1. A lost, warm-released or faulted residency, when a same-Host successor attach of the same session completed first, kept its session context uncancelled for the life of the process. The Manager now sets an overtaken record aside, and the late end cancels it when its runtime released. A runtime whose release failed still keeps its context.

A known benign race is unchanged. A same-Host successor attach that lands after a lost residency's lease is released, but before its warm watch has ended, is refused (already watched for warm release), and Factory retries it.

Upgrading to v0.9.0: an open gate survives failover, and a pooled Host stops growing

host v0.9.0 pairs with harness v0.39.0, whose restore keeps a session's open gate and resumes the turn that was parked at it. Nothing in Host's code path changes for it, but what an operator sees after a crash, a drain, a warm release or an outage abandon does:

  • Permission gate: the successor restores the gate open and resumes the parked turn. The answer is applied there and the approved tool runs exactly once. (Before, the turn was interrupted and the tool never ran.)
  • ask_user gate: for a tool that declares tool.UserInputReplaySafe — askuser.AskUser from tools v0.13.0 — the gate stays open and the answer reaches the waiting tool as its result. A tool that does not declare it keeps the old fail-safe: the gate is closed restore_unavailable and an answer settles no_op (the user is asked again).
  • A restored gated session is no longer idle. Its resumed turn waits at the gate, so a warm release leaves it resident, and a drain of the Host that restored it is crash-equivalent exactly as a drain of the original Host is: the release is refused within the grace, the runtime is left parked, the gate stays projected, and the next Host restores it again. (Since v0.17.0 the refused runtime is abandoned rather than parked; see "Upgrading to v0.17.0".)
  • A restore may journal GateResolved{abandoned} for a restored gate the resumed turn did not adopt, so a gate can carry more than one close. The gate projection folds closes as a set; a duplicate is a no-op.

Harness's known limits carry over: a gate raised by a subagent or foreign loop, or inside a turn that compacted, keeps the old closure; an answer saved between the gate closing and its step completing is lost if the runtime dies in that window (the tool never runs twice). v0.39.0 is not a one-way upgrade.

Two booked Host defects are fixed:

  • B1: a long-lived pooled Host no longer grows per session. The residency manager kept a record — the session context the runtime runs on and the ownership handle, which reaches the runtime — for every session the Host had ever held. Every path that ends a residency (warm release, drain, lost grant, persistence fault) now prunes it and cancels the context once the runtime was released or abandoned. A runtime left parked by a refused drain release keeps its context uncancelled, as before.
  • B2: no over-admission by one when a session is re-placed onto the same Host. The admission charge was keyed by session and Admit is idempotent, so a successor attach that admitted the session after the old residency's registry entry was removed and before its credit took the old charge, and the credit then uncharged the successor. The charge is now owned by the residency's generation: a re-admission claims it for the attach in flight, and only the owning generation's credit returns it.

There is no exported API change.

Upgrading to v0.8.1: a draining Host stops applying commands first

Before v0.8.1 a drain stopped a session's command consumer only when it released the runtime, after the idle wait and the checkpoint. A command admitted while the drain was waiting or checkpointing was applied by the draining Host under its own epoch, while its registration already said releasing. A long turn started there could turn a graceful release into a crash-equivalent one, and the checkpoint was not necessarily the session's last state.

The drain now halts each session's consumer first, the same step 0 a warm release takes. It waits, bounded by HOST_DRAIN_IDLE_BOUNDARY and the grace, for a pass already in flight. A halt that does not finish in time is recorded as halt_consumption and the release continues. A pass that was already running when the halt timed out finishes its whole batch (up to the reconcile batch size), so it may still apply commands after the checkpoint. This is the same as for a warm release. A command admitted during the drain stays pending, and the successor applies it. A warm release racing the drain can no longer resume a consumer the drain halted.

The halt and the idle wait each get their own HOST_DRAIN_IDLE_BOUNDARY. A session may therefore wait up to twice the idle boundary before its release begins. The drain as a whole is still bounded by HOST_DRAIN_GRACE, because every wait also stops at the grace. If the idle boundary equals the grace, a halt that uses its whole boundary leaves no time for the idle wait, and the release is crash-equivalent.

This includes a gate answer. An answer admitted after a drain begins is not applied by the draining Host. A session parked at a gate is released crash-equivalently, as before. What happens next depends on the gate. These are known limits under harness v0.38.0:

  • Permission gate: the successor applies the answer after restoring the session. But the restored turn is interrupted, so the tool does not run.
  • ask_user gate: the restore closes the gate restore_unavailable, so the pending answer settles no_op and is dropped. The user must be asked again. This is a regression from v0.8.0 for ask_user answers. There, the draining Host sometimes applied an answer admitted before the checkpoint, and if the resumed turn finished within the idle boundary, it released gracefully with the answer honoured.

Both limits are lifted from host v0.9.0 with harness v0.39.0: a permission answer runs the tool once on the successor, and with a replay-safe ask_user tool (tools ≥ v0.13.0) the answer reaches the tool. See "Upgrading to v0.9.0". They were harness bookings: restoring ask_user gates after failover, and continuing a restored permission-gated turn. The trade is deliberate. The alternative is applying commands under a releasing registration, which is the defect this release fixes.

Also in v0.8.1:

  • host_sessions_gate_waiting counts the resident sessions parked at a gate. Before, no source was wired, so it always read 0.

  • Each failed drain step is logged as host: drain step failed, with tenant_id, session_id, step, residency_epoch and host_generation. A refused runtime release (the crash-equivalent case) is logged at ERROR, the rest at WARN. The epoch is also kept durably, by sessionstore's retained epoch-fenced registration tombstone.

  • The v0.8.0 section below is corrected. The command in flight at a storage outage may settle refused in the S3 shape too, and every runtime adapter must forward department.PersistenceFaults.

There is no exported API change.

Upgrading to v0.8.0: a storage outage no longer wedges a session

Before v0.8.0 a brief PostgreSQL or S3 outage under a runtime's journal could wedge a session for good. One failed journal write, and the command being applied stayed applying under the runtime's own grant. No runtime may close its own attempt, so every later command queued behind it until the apply deadline rejected it. There were two shapes:

  • The runtime latched a persistence fault. harness does this by design: the durable state can no longer be proven, so the runtime refuses every later command.
  • The runtime refused one command without faulting. For example, its application prefix could not be written.

v0.8.0 treats either as loss of residency. Host watches harness v0.38.0's PersistenceFaulted, and the disposition applier reports an attempt the runtime failed and recorded nothing for. On either signal Host:

  1. marks the session releasing;
  2. stops its work;
  3. abandons the runtime crash-equivalently with AbandonResidency, which writes nothing;
  4. tombstones the route, releases the grant and credits the capacity.

Factory's pending sweep then re-places the session. The successor restores it from the journal. Every later command is applied exactly once.

The one command in flight at the outage may end in any of four states: applied, or rejected with the outcome refused or not_applied. (The v0.8.0 notes said applied for the S3 shape. The tests lane measured refused in about a third of S3-crash runs. The sealed runtime had already written the input's application prefix and recorded a refused disposition, with no effect.) Every non-applied outcome is honest: the input was never partly applied, and the model never saw it. The user must resend it.

EVERY PRODUCT RUNTIME ADAPTER MUST FORWARD department.PersistenceFaults. The capability is optional, which makes it easy to miss. It is harness's PersistenceFaultReporter and ResidencyAbandoner, surfaced through your adapter. Host discovers it by type assertion, so a wrapper that does not forward it compiles and runs. Without it Host never sees the fault, and a storage outage wedges the session exactly as before v0.8.0. Carbon and every other composer owe this, and test kits do too. Add a compile-time assertion that your runtime type implements department.PersistenceFaults.

One exemption. A gate_response whose runtime wrote its application prefix before the dispatch failed is never treated as stranded. That is the shape where the answer's GateResolved is durable and only its disposition was lost. Abandoning the runtime would throw away the answer the agent is holding and settle nothing, so the command stays applying, which is the known enduring-effect gap. The harness adapter reports the committed prefix as department.ErrPrefixCommitted.

Before abandoning, Host halts the session's consumer and waits (bounded) for any pass in flight. It credits the capacity only while the released residency is still the one it holds.

Limits you must plan around:

  • The acknowledged words of a command closed not_applied must be resent, as after any crash.
  • A runtime that does not offer department.PersistenceFaults is not supervised. It stays resident and blocked, and an ERROR is logged. See the forwarding obligation above.
  • If the outage is still on, the release's own durable writes fail. The grant stops renewing and lapses, and the registry row expires. Re-placement therefore waits for those bounds, and a restore attempted while storage is still down is refused.

Pin harness ≥ v0.38.0 with host ≥ v0.8.0. The reference adapter requires the two capabilities.

Upgrading to v0.5.0: create and restore

Every session a v0.4.0 Host held was wedged on its first command

Factory admits a session's first command as a create disposition record (a create's optional first message rides in its payload) and resumes a non-resident session with a restore. runtimecommand.Kind named only input, interrupt and gate_response through harness v0.35.0, so this Host's adapter refused both AFTER it had durably begun the dispatch attempt. No disposition frame was ever written, the store could never settle the record (sessionstore: inbox evidence (disposition) — absence of evidence, not a kind disagreement), the consumer never advanced its cursor past that command, and Closure.Validate refused the same kinds so no successor could close it either. The deadline sweeps skip an attempt-bearing record, so it never expired.

The user-visible effect: the session existed, the agent was resident, and nobody could talk to it. The create's first message was lost, clients saw accepted forever, and Factory's placement kept the session as open work permanently.

harness v0.36.0 adds KindCreate and KindRestore, and this release drives them:

  • A create carrying a first message. Host reads the stored Core CreateRequest (internal/createbody) and hands its blocks to the composition's BlockDecoder in the input-shaped body that decoder already reads, byte-identical to the input Factory would have admitted for the same message. A composition therefore names ONE encoding, not two. The command settles applied and the message reaches the model.
  • A bare create and a restore. Core's CreateRequest.Blocks is optional and RestoreRequest has no blocks member at all, so both cross carrying nothing and settle applied — their effect is residency, which this Host has already performed by the time the command is dispatched.
  • A create carrying a message with no decoder bound is refused, exactly as an input is. A Host that applied an empty first turn would settle the record perfectly while dropping the user's first words in silence, which is worse than the wedge it replaces because the wedge was visible.
Migrating a session a v0.4.0 Host stranded

Nothing needs to be done by hand. A create left applying with a durable attempt is closed not_applied by the next Host to take the session, under its own strictly later journal grant, and the session's command stream then continues from the command behind it. That closure is possible only because harness v0.36.0 widened Closure.Validate with the kinds; at v0.35.0 the recovery path refused exactly what the adapter did.

The create's first message is not recovered. It was never delivered and the command is settled as not applied; a client that wants those words sent must send them again as an input.

Every product runtime needs a recovery closer

A product runtime must implement department.AttemptCloser to recover a predecessor's stranded attempt. This is not limited to migration from v0.4.0: an ordinary pod eviction can leave an attempt in flight in an all-v0.5.0 fleet. Without the closer, Host refuses recovery as no_attempt_closer, leaves that command applying, and the consumer cannot advance past it. The entire session's command stream remains blocked, including later input and interrupt commands; the apply deadline cannot settle an attempt-bearing record. The bound journal session must write the closure under its own strictly later journal grant. The department.AttemptCloser documentation includes a copyable harness-backed method; since v0.16.0 harnessruntime.Target supplies it, and the default durable RuntimeProfile refuses a target that does not declare it.

Host logs attempt_closer_unavailable at WARN when it composes a launched runtime without the capability, before starting its command consumer. This check occurs at session attach because host.Compose has no launched runtime to inspect. The warning does not refuse the attach; a session with no stranded attempt can still run.

ONE-WAY UPGRADE

Once a session's journal holds any create or restore application prefix or disposition frame, harness ≤ v0.35.0 can neither replay nor reopen it — it fails closed on the unknown kind. Never roll a Host back below v0.5.0 (harness v0.36.0) after it has applied either kind. This is the same shape as v0.4.0's gate_response note and stacks with it.

Mixed fleets. A create admitted while a v0.5.0 Host owns the session is refused by a v0.4.0 successor after its own attempt, which re-strands the record until a v0.5.0 Host takes it. Do not run v0.4.0 and v0.5.0 Hosts over one session pool.

v0.6.0: create bodies are checked before an attempt

After claiming a create, Host checks its stored Core request before minting or recording an attempt. A malformed body or one naming another session or command is rejected while the command has no attempt, freeing the session's command stream. A body this Host may be too old to understand (unknown_field or unsupported_version) stays claimed and unattempted; a newer Host can take the claim, and Factory's apply deadline still bounds it. A body stored only by object reference is read from the command's scoped control store and checked by the same classifier. Once an attempt exists, only runtime evidence settles the command.

The precheck validates the generic create envelope and block-array shape. A product's block decoder can still refuse a block after the attempt; that runtime-specific failure remains visible as an applying command until a successor closes the attempt or an operator repairs the decoder.

Upgrading to v0.4.0: gates

Every AskUser and permission gate is published; the answer is a disposition command
  • Publication. Each resident session runs a gate publisher (internal/gates). At attach, under the fresh residency grant and before the runtime is restored, the Host makes one fencing write to the projection (never a bare epoch; the store-issued *ResidencyGrant), so a predecessor that is not dead fails its ownership check before it can begin an attempt on an answer. The publisher then subscribes to the runtime's committed stream (hints only), fences again (a no-op), folds the runtime journal (OpenEventReplayer, positioned by ledger sequence) into the set of open gates, and converges the sessionstore projection on it: OpenGate for a gate the journal holds and the projection lacks, ResolveGate for one the journal has closed. A lost subscription is resumed from the last sequence folded. A superseded grant stops the publisher. A gate already projected (by a predecessor) is never re-opened. The projection holds at most 16 open gates per session (sessionstore); a gate beyond that is not visible to Factory until another closes, is logged once, and is published when one does.
  • The projection is a function of the journaled GateOpened alone: harness sessionwire.ProjectGatePage under a ReadScope whose Core SessionID and RuntimeSessionID come from one read of the binding; answerability resident; deadline = the event's durable CreatedAt + the gate's ResponsePolicy.Timeout, or a per-kind TTL compiled into Host (permission 5m — harness journals its own 5m default, so this is only a fallback; ask_user and form 24h; open-url 1h).
  • Ownership is a residency epoch. On a disposition session the projection's CatalogRecord.LeaseEpoch is the residency of the last Host to write a gate. LoadGate returns it as OwnerEpoch (and no Host id); it is compared with the Host's residency grant, never with a journal epoch.
  • Applying an answer. A gate_response is checked after the claim and before the attempt — the last point a rejection is possible:
    • rejected (RejectDispositionCommand, no durable reason) when no Host could ever apply it: a missing or corrupt referenced object, not Core's strict GateResponseRequest, naming another session or command, a gate id that is not a non-zero UUID, or — for a gate the projection holds under this Host's mark — an ExpectedOpen* that is not the projected version;
    • blocked, nothing written for a limit of this Host: an unreadable projection, or a gate whose mark is not this Host's grant (fence not yet landed, or superseded). A block holds the session's whole command stream, interrupts included, until it clears; a claim that lapses while blocked is re-taken on the next pass;
    • dispatched otherwise, including for a gate the projection no longer holds — harness is the authority and settles an answer to a closed gate no_op. The adapter builds Admitted.GateResponse (source: user) under the authorized AttemptID, and the command settles from the runtime's evidence only.
  • What the outcomes mean. applied/applied: the answer resolved the gate. applied/no_op: the gate was already closed (a second answer, a timeout that won, a gate closed at restore). rejected/refused: harness refused the answer (an action the gate does not offer, values its schema rejects), settled from its evidence one pass after the attempt. rejected with no attempt: the body could never apply.
ONE-WAY UPGRADE

Once a session's journal holds any gate_response application — applied, no_op or refused — harness ≤ v0.34.0 cannot replay or reopen it. Never roll a Host back below v0.4.0 (harness v0.35.0) after it has applied one.

Limits you must plan around (harness v0.35.0)
  • A gated session cannot be released gracefully, so its drain is crash-equivalent. harness releases a session only when it is whole-session idle, and a session parked at a gate is not. The drain bounds that release by its grace (it used to hang forever) and then leaves the refused runtime parked: its context is not cancelled, so it writes nothing — no TurnInterrupted, no GateResolved{abandoned} — and the gate stays open and projected, as after a crash. The parked runtime keeps its journal lease until its process exits, so another Host in the same process cannot attach that session until then (it is refused lease held); cmd/host exits after its drain. The successor then restores the session as it restores a crashed Host's, and a permission gate is restored open. A parked runtime is uncancelled, not stopped. It keeps its goroutines, accepts a Submit, and keeps its own gate timers — so a permission gate's five-minute default can fire on a drained Host whose publisher has stopped, resolving the gate and continuing the turn against a stale projection. A Host that drains with a gate open MUST exit; cmd/host does. Note also that Stop skips the manager's context cancellation for the whole process when any one session's release was refused. Superseded in v0.17.0: the drain now abandons the refused runtime crash-equivalently (stopped, lease released, gate preserved), so only a runtime that cannot be abandoned is still parked; see "Upgrading to v0.17.0".
  • After a restore, a permission gate is restored open and answerable, but the turn that was parked at it is TurnInterrupted: the approval is applied and nothing runs the tool. An ask_user gate is closed at restore (restore_unavailable) and an answer to it settles no_op. Both are lifted by harness v0.39.0 (host v0.9.0); see "Upgrading to v0.9.0".
  • A crash between the runtime's GateResolved and its disposition frame leaves the command applying forever (never a false not_applied): the same liveness gap input has.
Capability: how a Factory knows a Host can apply a gate response

A Host that applies gate_response advertises Core's capability token hostlink.command.gate_response (sessionwire.HostLinkCapabilityGateResponse, core v0.11.0) in its connect reply's hostlink_methods, after the five reserved methods. A Factory admits — and wakes — a gate response only for a Host whose reply Supports it; no earlier Host advertises it. A token is not a method: nothing dispatches on it, and sent as an RPC it is answered from the channel arm like any unknown name. Host advertises it only when its composition wires both gate seams (publication and the pre-attempt check); host.Compose always does.

Referenced command bodies

The composed Host advertises Core v0.13.0's hostlink.payload.reference (sessionwire.HostLinkCapabilityPayloadReference). For a create, input, or gate response above SessionStore's 64 KiB inline limit, the applier resolves PayloadRef through the session's control SessionStore, using the command's tenant and session and the command-payload kind. It checks size and SHA-256 while reading, then runs the same body checks as an inline command. The runtime and harnessruntime receive verified bytes and never dereference the object; a runtime handed an unresolved PayloadRef (only a legacy composition with no object reader can produce one) must refuse it, as harnessruntime does. Missing, foreign, wrong-kind, corrupt, or oversized objects are rejected before an attempt. Transient store faults block the pass for retry. The memory limit defaults to 8 MiB; Composition.MaxCommandBodyBytes may be set above 64 KiB through 16 MiB, Harness's object-backed runtime body ceiling.

The projection's residency mark equalling this Host's grant is not a capability signal. It is the applier's ownership check (an answer to a gate whose mark is not this Host's grant blocks until it is), and a Factory must not read CatalogRecord.LeaseEpoch to decide anything.

Mixed fleets. An answer admitted while a v0.4.0 Host owns the session can still be re-placed onto a v0.3.0 Host, which claims it, begins its attempt and cannot apply it: it sits applying until a v0.4.0 successor settles it not_applied. Do not run v0.3.0 and v0.4.0 Hosts serving gating agents together. factory v0.5.0 refuses admission, and filters pooled placement, on the token.

Other v0.4.0 changes
  • A superseded generation still releases its sessions. A newer incarnation of the same HostID started before the old one stops makes the old drain's target withdrawal fail permanently (host target generation); the drain used to abort there, before releasing any session lease, and every later Host got epoch_mismatch. The row now belongs to the newer generation, the drain skips it (service.ErrTargetGenerationSuperseded) and releases every session. A platform should still not overlap two generations of one HostID.
  • A successor takes a crashed predecessor's command claim, and a Host re-takes its own lapsed claim, before the attempt (both used to resume at the attempt, which the store refuses, forever).
  • Collaborators.Logger (optional *slog.Logger, nil discards): attach refusals at hydration are WARN with tenant, session, step, code and reason; placement-race refusals are DEBUG; a failed gate pass and a failed advertisement heartbeat are WARN. cmd/host logs JSON to stderr.

Upgrading to v0.3.0

HOST_INTERNAL_ENDPOINT is a BASE, and Factory must move with Host

One pooled Host serves several tenants, each at its own HostLink address: base + /hostlink/ + PathEscape(tenant). As of v0.3.0 the configured InternalEndpoint (HOST_INTERNAL_ENDPOINT) is that base — a ws/wss scheme and an authority, nothing else — and a Factory derives each tenant's address with Core's sessionwire.HostLinkEndpoint(base, tenant). Host advertises the base unchanged in its capacity report and registry observation.

host.New / host.Compose now refuse at startup:

value refusal
not a valid internal endpoint (http://…, credentials, a query) *sessionwire.HostLinkEndpointError, code invalid_base
the v0.2.1 per-tenant spelling …/hostlink or …/hostlink/<tenant> code base_names_tenant
any other path (including an ingress prefix such as https://gw/pods/h7/), or a bare # code base_not_bare
so long that not even a one-byte tenant fits code too_long
an authority no client can dial although Core accepts it: ws://h:, ws://h:99999, ws://h:0, ws://h:80:90 host.ErrUndiallableEndpoint

Each is an *InvalidOptionsError{Code: invalid, Field: "InternalEndpoint"} whose cause is reachable with errors.As / errors.Is. A Host behind a path-prefixed ingress must use host-based routing, a port per Host, or in-cluster Service DNS.

Compatibility window. Every released Factory up to and including v0.3.0 dials the advertised endpoint verbatim — measured against factory v0.3.0; no earlier one can derive, since HostLinkEndpoint first shipped in core v0.10.0 — and dialling a bare base gets 404, so such a Factory cannot reach a v0.3.0 Host. The failure is silent: measured with released factory v0.3.0, a created session stays pending, is placed nowhere, and Factory logs nothing — a failed dial classifies as ErrHostUnreachable, which it only counts. Upgrade Factory together with Host (to the first Factory that derives addresses with HostLinkEndpoint). In the other direction, a v0.2.1 Host reconfigured to a bare base already works with a deriving Factory, because v0.2.1 already served every tenant at /hostlink/<tenant> — so a fleet can move Factory first and then Host. A controller that writes …/hostlink/<tenant> into HOST_INTERNAL_ENDPOINT must write the bare base instead, or a v0.3.0 Host refuses to start.

Host's routing is unchanged, and the prefix is Core's own sessionwire.HostLinkPathPrefix. routes_endpoint_test.go holds the real Service.Routes() to HostLinkEndpoint over Core's golden tenant list and a fuzz target, observing which tenant each derived address authenticates as.

A re-placed session resumes its conversation instead of restarting it

Factory sends every attach as create (both inputs its attach mode is chosen from are dead on a disposition record), and harness does not check that a session id it is asked to create under is fresh: creating over an existing conversation re-opens that stream, appends a second SessionStarted and restores nothing, with no error. Before v0.3.0 a Host launched every create fresh, so a session released by one Host and placed on another silently lost its conversation.

As of v0.3.0:

  • The runtime is launched under the runtime session id the session's immutable binding names (SessionBinding.RuntimeSessionID — the id Factory derived, never the Core session id), via rig.WithSessionID. department.CreateRequest / RigCreateRequest carry it as RigSessionID, and a Rig that launches or restores under any other identity is refused with department.ErrRigSessionIdentity (the mis-launched session is released).
  • A create is decided by the runtime's journal, read under that id from the journal store registered for the session's (tenant, storage binding) in Collaborators.JournalStores: no conversation → create; a conversation → restore it. A restore still refuses when the target requires a checkpoint the session has not got. harness's catalog (ReadMeta) is consulted first and the ledger is the authority, because the catalog is a best-effort cache. Only a create consults the journal; an explicit restore is launched as before.
  • The two refusals carry different codes. When no journal store serves the session's binding, Host cannot tell a new session from a re-placed one and refuses the create with runtime_unavailable. When the journal read fails (a storage fault), the attach is refused with the empty, unclassified code, like every other durable-read failure. Neither ever starts the conversation over. Warning: factory v0.3.0 treats both as "try the next candidate, retry next sweep". It only counts a runtime_unavailable refusal and never logs it, so a fleet-wide one shows up as sessions that never place, not as log lines; the empty-code refusal is logged at WARN ("a pooled candidate failed the attach"), carrying the Host id but not Host's reason. The attach error Host returns names the reason; a failed journal read is a *RuntimeJournalProbeError naming the tenant, binding and runtime session (v0.4.0).
  • A binding whose RuntimeSessionID is not a non-zero UUID is refused with runtime_unavailable (v0.4.0; v0.3.0 gave it the empty code). It is a permanent statement about the session — the binding is immutable and every Host reads the same one — so the empty code, which reads as a transient store failure, was wrong. Core has no class meaning "and no Host ever will"; runtime_unavailable ("this Host cannot run this session") is the nearest, and is what a restore with no durable state already gets.
  • The journal store for each binding must therefore be the released harness session store (*harness/pkg/sessionstore.Store, or a value embedding one): the same journal the runtime writes and settlement reads. host.Compose refuses a reader that is not one (InvalidCompositionError on Collaborators.JournalStores), because every create routed to it would be refused.
  • Collaborators.RigSessionIDs is deprecated and optional; it is consulted only for a record with no binding (legacy). Such a record's journal is never probed, so a create over a legacy record is now refused (v0.2.1 launched it); no shipped path creates one a Host can hold.
  • What the journal check does not close. The journal is read under the residency lease, before the launch. A Host that reads "no conversation", stalls inside the launch and loses its lease can still create over a conversation a successor began meanwhile (measured: a second SessionStarted; the conversation survived the next restore). Host cannot close this alone; it needs harness to create-if-absent under its own journal lease, which is booked for harness.
  • Endpoints Host does not refuse although they are not usable from another pod: the unspecified address (ws://0.0.0.0:1), a link-local zone (ws://[fe80::1%25en0]:9000), a raw non-ASCII IDNA host (advertised verbatim, not converted to punycode), and invalid DNS names such as ws://h;x and ws://-h. Core accepts all of them; they are booked.

Known limitations you must read before deploying

These are properties of the code as it stands, not a backlog. Each is written here because an operator who meets it for the first time in production has been surprised by something this file could have told them.

This entry replaced a deployment gate. It used to say a whole-Host drain request naming an empty TenantID short-circuited the tenant check, so a link authenticated as tenant-a could start a drain covering tenant-b's resident sessions — availability, not confidentiality — and that pooled multi-tenant deployment was gated on it. R-1 closed the hole and that gate is lifted. What follows is what is now refused, stated no wider than it is.

internal/realtime/hostlink/drain.go, drainScope. Two requests are refused, each with RefusalWrongDrainScope and the Core class runtime_unavailable, and each before the drain state machine — a Drainer that has begun cannot be un-begun.

  1. A request naming an empty TenantID — the whole-Host scope — is refused before the tenant comparison, because there is no tenant on it to compare. Every link this package serves is tenant-authenticated (NewMultiplexer requires a valid TenantID), so that scope has no link it may arrive on.
  2. A request naming this Host's fixed session whose requesting tenant does not currently hold that session is refused at the last rung, on a Residencies.Get of {the link's tenant, the fixed session}. Without it the resolver compared a session against a session and attributed nothing: a dedicated Host's FixedSessionID is one bare SessionID handed to every tenant's Multiplexer, so a link authenticated as tenant-b holding nothing could name it and begin the Host-wide drain that released tenant-a's session.

The attribution in (2) is sound because of capacity, and it is sound only because of capacity. Options.validatePlacement pins Capacity to 1 for dedicated placement, so a Host with a fixed session holds at most one resident session; "this tenant holds the fixed session" and "this tenant owns everything a Host-wide drain would touch" are therefore the same statement. On a Host that could hold two tenants at once they would not be, and this rung would not be sufficient.

So exactly one drain is reachable over HostLink: a dedicated Host's fixed session, asked for by the tenant that currently holds it. A pooled Host answers no HostLink drain at all: it has no fixed session, so the session scope is refused too.

A dedicated Host refuses every HostLink drain between process start and its first attach, and that is a behaviour rather than a caveat — Factory will meet it. Rung (2) reads current residency, so until a session is resident there is no tenant that holds the fixed session and every drain request is answered runtime_unavailable with RefusalWrongDrainScope. The reason is that the refusal is the correct answer: a Host holding nothing has nothing for a HostLink drain to cover, and the alternative — acknowledging a drain of nothing — would hand a placement controller a generation it could poll to "drained" without anything having been released. What a controller should do is take the process-lifecycle path (terminate the workload) for a Host it wants gone before it has been used, and reserve the HostLink drain for a Host that is actually holding the session it placed.

The too-early refusal is indistinguishable from the refusal a tenant gets when ANOTHER tenant holds this Host's fixed session — same Refusal, same reason, same Core class, because both are rung (2). It is not the refusal a foreign tenant gets: a request naming a tenant other than the link's is refused three rungs earlier with RefusalForeignTenant. So the one distinction a controller cannot make is "nothing is attached yet" versus "someone else's session is resident here".

That is deliberate and must stay. Distinguishing the two would disclose cross-tenant occupancy — telling tenant-b that somebody is resident on a Host advertising cross_tenant_isolated — which would pay for an availability fix with a confidentiality leak, and confidentiality was never what R-1 was about. The cost falls where it can be borne: a placement controller can resolve the ambiguity from its own attach state, which it has and this Host does not owe it. Do not "fix" this by splitting the refusal.

AMENDED (O7.3): rung (2) is relaxed on the OBSERVATION path, and only there. hostlink.drain_status also admits the caller that began this very drain, matched on the tenant and session the drain was begun with. Without it the terminal drained answer was unreachable to the only caller entitled to it: release drops the residency, so rung (2) started refusing the beginner at exactly the instant the answer it was waiting for became true. The mutator path is NOT relaxed — hostlink.drain after release is still refused, because beginning a Host-wide drain is attributed to a holder. It discloses nothing new: the match set is a subset of "callers that already received the acknowledgement", a tenant that never held the fixed session began nothing and is refused identically, and that is measured, after completion, by TestRelaxingRungEightForObservationDisclosesNothingToAnyoneElse. Do not generalise the relaxation to "a drain has begun" or to the session alone; either hands cross-tenant occupancy to any link that can name the fixed session.

The transport closes when the process stops, never as a drain step

This entry replaced a design that made the terminal drain answer unreachable. internal/lifecycle used to hold a Link seam and close it as the drain's last cleanup step — before settledState assigned the terminal state. So a HostLink-initiated drain destroyed the connection carrying the drained answer before that answer existed, and the disconnect a Factory was left with is Centrifuge DisconnectShutdown 3001 in every outcome — including the one settledState deliberately withholds drained for. That signal is therefore ambiguous between "release finished, delete the workload" and "FinishRelease refused, this Host still holds the lease", and Core says directly that HostLinkDrainObservation exists "for a Factory to observe without inferring release completion from a transport close".

compose.Service.Stop closes the transports now, after Wait returns. "LAST" is unchanged — it is still after every release step — and TestStopReleasesTheSessionAndClosesEveryTransportLast still holds it. A failure to close is recorded under lifecycle.StepCloseLink rather than returned. TestTheDrainOwnsNoTransportShutdown is the reflective trip-wire: no field of lifecycle.Options may be a transport shutdown.

A link-drained Host stays up and keeps answering, with Ready() == false and Live() == true. That is not a new cost — Run only leaves on ctx.Done(), so such a Host lingered before this change too; it simply lingered refusing connections. Live()'s doc used to be a sentence wider than its probe (it claimed liveness turns false once release finishes); the code was always started && !stopped, the code is the correct one, and the doc was fixed to match.

A released session's routes are dropped explicitly. Closing a transport used to drop its links' bindings as a side effect, so nothing needed to say it. With the transport surviving, sessionWork.stop calls InvalidateSession for the released key — scoped to the session, not the link, so releasing one session does not cut a Factory's routes to the others on the same connection.

A closed Host answers 503, not 400. resolve returned an anonymous error that the handler mapped to 400 "HostLink requires a valid tenant", so a Factory reconnecting to a stopped Host read a client error naming its own credential. It is errHostClosed now, answered 503.

The whole-Host drain is reachable only through the process-lifecycle path — Service.Stop calls Drainer.StartDrain with the zero scope directly and never passes through this resolver — which is the path a platform's termination signal already takes, and it is unchanged.

What this does NOT claim.

  • Not "scoped by construction". An earlier version of this entry said the fixed-session drain was scoped by construction. It was not, and a gate probe demonstrated the hole on the tree that claimed it. What scopes it is rung (2), which is a line you can point at, resting on the capacity rule above.
  • Rung (2) is a residency read, not an ownership proof. It says this tenant holds this session on this Host now. It is not the lease, it grants nothing, and it is not durable evidence of anything.
  • Not a confidentiality claim, which was never at issue: the defect was availability. No tenant could read another's material or address its sessions.
  • Not a Factory drain path. A cmd/controller gets no drain over HostLink; if one is needed it must be tenant-scoped or service-principal, and re-admitting the empty tenant would reopen exactly what this closed.

Measured, in both halves, by behaviour and not by reading: TestATenantLinkCannotDrainAnotherTenantsSessions (two tenants, both holding live resident sessions, one shared Drainer) and TestADedicatedHostRefusesADrainFromATenantThatDoesNotHoldItsSession in internal/compose; TestAWholeHostDrainIsRefusedOnEveryTenantAuthenticatedLink and TestADrainOfTheFixedSessionIsRefusedUnlessTheLinkSTenantHoldsIt in hostlink.

Closed as O7.1-hostwide-drain-crosses-tenants / R-1.

A composed Host applies a command, and what that word means

This entry replaces three earlier ones, and the first two were wrong in the same direction — a sentence wider than its evidence. The first said a Host "can consume its inbox against a real store", which was an ADAPTER result stated about a composed Host. The second said a composed Host "cannot attach a disposition session, so it consumes nothing", which was true until the attach-time journal fence was removed. The third said a composed Host deliberately refuses to dispatch, and that is now false too: it described commands.NoDispatch, which has been deleted, and listed as future work the three-repository sequence that has since landed.

What a composed Host does. It attaches a disposition session, reaches BeginOwnership, starts the session's durable command consumer, and for each command in acceptance order: claims it under the residency GRANT this store issued (ClaimDispositionCommand takes a *ResidencyGrant, not an epoch, so a caller cannot name one); authorizes one attempt, recording an immutable identity together with the RUNTIME's own journal grant; dispatches it, carrying that identity to the runtime; and settles it from the durable disposition the runtime wrote. It steps over terminal records and advances the durable consumption cursor.

This is measured in composition (internal/compose.TestAComposedHostAppliesADeliveredCommandEndToEnd) and end to end against both released stores on separate backends (internal/settlement.TestACommandAppliesAndSettlesAcrossBothReleasedStores).

What applied means, exactly, and it is narrower than the word. Under the attempt's journal grant, the runtime durably recorded that it accepted this command into its execution path. It does not mean a turn started; it does not mean a turn folded; it does not mean a later TurnRejected cannot follow; and it does not mean a queued input survives a crash — the runtime's loop mailbox is in memory. Do not widen this sentence, including in any user-facing vocabulary: a product-level "applied" for an input that a crash then discards is a claim this Host does not make.

Host authors no outcome. SettleDispositionCommand takes a command, a revision and a residency and nothing else; the store derives what it expects from its own immutable record, obtains the evidence through its configured reader and verifies it before the write. Host reports the arm the store chose.

Missing evidence is not proof that nothing was applied. A settlement that cannot read a disposition leaves the command applying at an unmoved revision and blocks the pass. A Host that re-dispatched on absence would re-drive an effect that may already have committed. Host's honest exactly-once claim is "at most one authorized attempt, settled only from the runtime's durable disposition".

There is no Host-authored rejection once an attempt exists. Before one, Host rejects a gate_response or create whose immutable body no Host could apply. The apply deadline is still Factory's deadline reconciler's (§10.4), never a Host's.

What a deployment must wire, and what Host cannot check

A disposition session's journal is not in the orchestration store. The immutable binding says where it is, and a harness Store answers only for the keyspace it owns — it holds no registry of the bindings it serves, so it cannot route on Binding.StorageBindingID. A product therefore supplies Bootstrap.JournalStores(), one reader per binding; Host builds the router and hands it to Bootstrap.Store(ctx, evidence), which must open the store with sessionstore.WithDispositionEvidence.

Three wiring conditions are unenforceable, and they share one signature — each fails only after every dispatch has happened, strands the command applying with a real effect behind it, and is invisible until the first settlement:

# condition diagnosed as
(a) the product takes the reader and drops it evidence_unavailable
(b) the orchestration tenant and the journal store's tenant disagree evidence_unroutable
(c) a router key is present but names the wrong journal store evidence_unavailable

(b) and an unregistered binding are machine-distinguishable inside Host and are reported as evidence_unroutable, which points an operator at the wiring rather than at a runtime that did its job. (c) is genuinely ambiguous at settlement time and correctly so — that store is healthy, at the correct tenant, and truthfully reports it holds no such record. Closing it needs a construction-time answer (a journal store declaring which bindings it serves) that no released API offers; a harness Tenant() accessor would close (b) and not (c).

Operationally, a blocked pass answers the delivery accepted, makes no further progress, leaves Consumer.Failures() at zero (a blocked pass returns a nil error) and logs nothing. The signal is the Prometheus gauge host_sessions_command_blocked. Unlike before, a non-zero value is a fault: it means a command this Host could not settle, not one it refused to start.

Host opens no journal writer, and must not

The attach sequence used to commit an opening in-stream journal fence at step 3, before hydration. It is gone. Store.OpenJournal binds the session scope through bindSessionScopeMode(..., ProtocolModeLegacy), which conflicts with the disposition witness AcquireResidency pins — so that fence failed for every session a Host can hold, and no composed Host ever reached BeginOwnership.

Under Bundle B, where disposition is the only supported mode, the journal grant is the runtime's and Host never takes one. The journal epoch Host needs is read from the launched runtime through department.LeaseEpochReporter and reported as residency.Residency.JournalEpoch. compose.Options declares no session opener, so the absence is held by the compiler, and internal/residency.TestNoProductionFileOpensAJournalWriter holds it structurally. Do not reinstate it.

Build and test

The Go baseline is 1.26.9. Verify standalone, against the pinned dependencies:

GOWORK=off go test -race ./...
GOWORK=off make check

make check runs fmt-check, vet, staticcheck, gosec, vuln, test, fuzz and build; make secure runs the static and vulnerability gates only.

License

Apache 2.0. See LICENSE.

Documentation

Overview

Package host is the Looprig Department runtime host.

A Host owns resident sessions for one tenant: it consumes commands, keeps runtime targets resident, serves the HostLink realtime surface, and drains on release. It is consumed BY Factory and imports nothing from it; the dependency boundary that states this is enforced by import_boundary_test.go rather than by convention.

A Host is constructed from validated Options and holds a resolved, immutable configuration: identity and tenant, the HostLink address it advertises, its Department, its collaborators, and the placement, capacity and timing bounds it runs under. The Department it serves — the immutable set of launch targets, and the segregated runtime capabilities Host consumes — lives in the department subpackage. Residency, command consumption, HostLink and drain follow.

Index

Constants

View Source
const DefaultPublicJournalCapacity = 1024

DefaultPublicJournalCapacity is the session count NewPublicJournals keeps when given no positive capacity.

View Source
const HostLinkPathPrefix = compose.HostLinkPathPrefix

HostLinkPathPrefix is the path every HostLink connection arrives under on the Handler; the segment after it is the tenant the connection claims and the server then authenticates. It is Core's sessionwire.HostLinkPathPrefix, the prefix sessionwire.HostLinkEndpoint derives every tenant's address with.

View Source
const MaxCommandQueueSize = hostconfig.MaxCommandQueueSize

MaxCommandQueueSize bounds the command queue: an unbounded one converts backpressure into memory growth.

View Source
const MaxReconcileBatch = hostconfig.MaxReconcileBatch

MaxReconcileBatch bounds one reconciliation pass, so a Host that has drifted far does not attempt to converge in a single unbounded sweep.

View Source
const MinClaimAttemptsBeforeDeadline = hostconfig.MinClaimAttemptsBeforeDeadline

MinClaimAttemptsBeforeDeadline is how many claim lifetimes must fit inside the apply deadline, so a claim that lapses mid-apply still leaves a whole attempt inside the deadline. It is stricter than the runbook's "below"; see internal/hostconfig for the failure model it is chosen against.

View Source
const MinHeartbeatsBeforeExpiry = hostconfig.MinHeartbeatsBeforeExpiry

MinHeartbeatsBeforeExpiry is how many heartbeat intervals must fit inside the registry expiry. "Safely below" is a MARGIN, not `<`: three means two consecutive heartbeats may be late or lost before Factory stops seeing this Host. The rule is internal/hostconfig's; this is its published value.

View Source
const StepCloseStore = "close_store"

StepCloseStore names the Stop step that closes the session store Compose opened; a failure there is reported on the DrainReport under this step.

Variables

View Source
var ErrPublicJournalScope = errors.New("host: the journal read names another tenant or runtime session than the public journal was bound to")

ErrPublicJournalScope reports a read naming a journal other than the one a PublicJournal was bound to. A JournalResolver reads under the binding's RUNTIME session id, so a request naming anything else is a composition defect, and projecting it with this session's identities would be wrong.

View Source
var ErrServiceActive = compose.ErrServiceActive

ErrServiceActive means the Service is running or has admitted work.

View Source
var ErrServiceDisposed = compose.ErrServiceDisposed

ErrServiceDisposed means disposal has begun and the Service cannot restart.

View Source
var ErrServiceNotStarted = compose.ErrServiceNotStarted

ErrServiceNotStarted means Start has not completed successfully.

View Source
var ErrUndiallableEndpoint = hostconfig.ErrUndiallableEndpoint

ErrUndiallableEndpoint is the cause of an InternalEndpoint refusal whose authority no client could dial — an empty port, a port outside 1..65535, or more than one port — although Core's InternalEndpoint.Validate and sessionwire.HostLinkEndpoint accept it. Reach it with errors.Is. A base Core itself refuses carries a *sessionwire.HostLinkEndpointError instead, whose Code names the reason.

Functions

func Run added in v0.2.0

func Run(ctx context.Context, blueprint Composition, listen ListenOptions) error

Run composes the Host, starts it, serves Routes until ctx is done or the listener fails, then drains within Drain.Grace and shuts the listener down.

THE ORDER IS COMPOSE, START, SERVE, DRAIN, CLOSE, and each refusal happens as early as it can be made: a Composition that cannot be validated opens no store; one whose first advertisement the directory refuses fails at Start rather than running invisibly. The drain runs BEFORE the listener closes, because a Factory reads the bounded drain-status observation over HostLink and a listener closed at the signal would leave it inferring completion from a disconnect.

A drain failure is returned as an error, one DrainFailure per failed step joined with the shutdown's own, because a process that drained with failures has left something for an operator to reconcile and must not exit zero. Run's caller decides whether that is fatal.

Types

type AttachError added in v0.2.0

type AttachError struct {
	Step              string
	Code              sessionwire.HostLinkErrorCode
	CurrentLeaseEpoch uint64
	Reason            string
	Cause             error

	// Unreleased names every compensation that itself failed, so "the attach
	// failed and took nothing" can be told from "something is still held".
	Unreleased []string
}

AttachError reports a refused or failed attach.

Step names WHERE it failed. Code is the HostLink class a Factory branches on and is EMPTY when the failure is not a placement outcome — a store that failed, a launch that failed outright — which is Core's own rule for such failures. CurrentLeaseEpoch is set for epoch_mismatch when the OTHER holder's epoch was readable; it is that holder's, never this Host's, and a caller must not bind with it.

func (*AttachError) Error added in v0.2.0

func (e *AttachError) Error() string

func (*AttachError) HostLinkCode added in v0.2.0

func (e *AttachError) HostLinkCode() (sessionwire.HostLinkErrorCode, bool)

HostLinkCode returns the refusal class Factory branches on, and whether this failure has one at all.

func (*AttachError) Unwrap added in v0.2.0

func (e *AttachError) Unwrap() error

Unwrap returns the typed error a lower layer produced, if any.

type AttachRequest added in v0.2.0

type AttachRequest struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	AgentID   sessionwire.AgentID

	// Mode is Core's closed attach mode: create or restore.
	Mode sessionwire.HostLinkAttachMode

	// RuntimeCompatibilityID is the build the session was placed on. Optional;
	// when set it must equal the target's, and a resident session's.
	RuntimeCompatibilityID string

	// ActorID is who the attach acts for — a service identity, not an end
	// user — and is REQUIRED so the attach can be attributed. TraceID is
	// optional correlation.
	ActorID string
	TraceID string
}

AttachRequest asks this Host to make one session resident, in process.

type AuthVerifier

type AuthVerifier interface {
	// VerifyTenant reports whether a presented credential acts for the tenant.
	VerifyTenant(context.Context, sessionwire.TenantID, string) error
}

AuthVerifier authenticates an inbound HostLink or command caller.

type Checkpointer

type Checkpointer interface {
	Checkpoint(ctx context.Context, tenant sessionwire.TenantID, session sessionwire.SessionID) error
}

Checkpointer commits the checkpoints a nonterminal release requires.

IT HAS NO IMPLEMENTATION IN THIS MODULE AND THAT IS A STATEMENT ABOUT WHERE THE KNOWLEDGE LIVES, not an omission to be filled in with a no-op. A release checkpoint is the runtime's conversation state and the workspace's contents, and Host holds neither. A composition that silently treated the step as done would report a drained Host whose sessions had lost whatever was not already durable, which is the one drain failure that destroys work. So the seam is REQUIRED rather than optional: a Host composed without one does not start.

type Clock

type Clock interface {
	Now() time.Time
	NewTimer(time.Duration) *time.Timer
}

Clock is the time source.

type Collaborators added in v0.2.0

type Collaborators struct {
	// Backend is the storage composite the session store is opened over. It
	// is REQUIRED. Compose opens the store itself — see Compose for why a
	// caller may not hand one in.
	Backend *storage.Composite

	// StoreOptions are passed to sessionstore.Open ahead of the settlement
	// evidence option Compose adds. WithDispositionEvidence must NOT be among
	// them: the store refuses two, so a caller's would fail the composition
	// rather than silently replace the router Compose built.
	StoreOptions []sessionstore.Option

	// JournalStores are this deployment's runtime journal stores, one per
	// (tenant, storage binding): the settlement evidence readers, and — as of
	// v0.3.0 — what an attach reads to decide whether a session's conversation
	// already exists. REQUIRED and non-empty: a Host with no reader cannot
	// settle anything and is refused at composition rather than at the first
	// command.
	//
	// A CREATE IS DECIDED BY THE JOURNAL, NOT BY THE ATTACH MODE. Factory sends
	// every attach as create, and harness does not verify that the id it is
	// asked to create under is fresh: a create over an existing conversation
	// silently re-opens that stream and restores nothing. So Host looks first,
	// under the runtime session id the session's binding names. The reader
	// registered for the binding must therefore be the released harness
	// session store (*harness sessionstore.Store, or a value embedding one) —
	// the same journal the runtime writes. Compose REFUSES a reader that is
	// not one. A session whose binding has no reader at all is refused a
	// create with runtime_unavailable; a journal READ that fails refuses the
	// attach with the empty, unclassified code, like every other durable-read
	// failure. Neither ever starts a conversation over.
	JournalStores map[EvidenceKey]sessionstore.DispositionEvidenceReader

	// Registrar produces the Department this Host serves. REQUIRED.
	Registrar Registrar

	// Checkpointer commits the checkpoints a nonterminal release requires.
	// REQUIRED; see Checkpointer.
	Checkpointer Checkpointer

	// Auth verifies the service credential on every HostLink connection.
	// REQUIRED.
	Auth AuthVerifier

	// Workspaces materializes and releases session workspaces. REQUIRED.
	Workspaces Workspaces

	// NamespaceLayout is REQUIRED; see NamespaceLayout.
	NamespaceLayout NamespaceLayout

	// RigSessionIDs is OPTIONAL and consulted only for a record with no
	// binding.
	//
	// Deprecated: see RigSessionIDs.
	RigSessionIDs RigSessionIDs

	// Logger receives this Host's operator diagnostics. It is OPTIONAL: nil
	// discards, and nothing about running depends on it. An attach refused
	// while hydrating a session — no journal store serves its binding, a
	// restore needs a checkpoint it has not got, its binding names no runnable
	// runtime identity, its journal could not be read — is logged at WARN,
	// because a Factory counts those refusals without logging them. A failed
	// gate publication and a failed advertisement heartbeat are WARN too;
	// ordinary placement refusals (epoch_mismatch, no_capacity,
	// not_admitting) are DEBUG.
	Logger *slog.Logger
}

Collaborators is everything a deployment supplies that this module cannot know: which store, which journals, which agents, how to checkpoint, who may connect, where workspaces live.

type Composition added in v0.2.0

type Composition struct {
	// MaxCommandBodyBytes bounds a referenced command body. Zero defaults to
	// 8 MiB; explicit values must exceed SessionStore's 64 KiB inline limit
	// and stay within Harness's 16 MiB object-backed runtime body limit.
	MaxCommandBodyBytes int64
	// Options is the Host's configuration. ITS COLLABORATOR FIELDS MUST BE
	// NIL — Department, SessionStore, Workspaces, Clock and Auth — because
	// Compose supplies every one of them from Collaborators: the Department
	// from the Registrar, the store it opened, the Workspaces, the clock, the
	// verifier. A caller-supplied one would be a second store or a second
	// clock beside the one the composition runs on, and is refused.
	Options Options

	// Generation is this process's incarnation, non-zero. It belongs to the
	// RUN and not the configuration: a Host restarted on identical Options is
	// a new generation, which is why Options does not carry it.
	Generation uint64

	Link  LinkOptions
	Drain DrainOptions
	// LiveText is opt-in. Nil keeps the committed-only relay. Reasoning and tool
	// steps require IncludeReasoning or IncludeToolSteps as well as a non-nil
	// LiveText option.
	LiveText *LiveTextOptions

	// CompatibilityTimeout bounds the synchronous compatibility wait; WorkPoll
	// is how often resident sessions are asked what they are doing. Both are
	// required and positive.
	CompatibilityTimeout time.Duration
	WorkPoll             time.Duration

	// Clock is optional; nil means the system clock.
	Clock CompositionClock

	// RuntimeProfile is RuntimeProfileDurable unless set: every registered
	// target must declare department.Recovery in full or Compose refuses the
	// composition. See RuntimeProfile.
	RuntimeProfile RuntimeProfile

	Collaborators Collaborators
}

Composition is one runnable Host, described.

type CompositionClock added in v0.2.0

type CompositionClock interface {
	Now() time.Time
	After(time.Duration) <-chan time.Time
	NewTimer(time.Duration) *time.Timer
	NewWarmTimer(time.Duration) WarmTimer
}

CompositionClock is the composition's single time source: the Host's Clock, the drain's bound and the warm releaser's countdown, all one timeline. Its zero value in Composition means the system clock.

type DrainFailure added in v0.2.0

type DrainFailure struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	Step      string
	Err       error
}

DrainFailure is one drain step that did not succeed.

func (DrainFailure) Error added in v0.2.0

func (f DrainFailure) Error() string

func (DrainFailure) Unwrap added in v0.2.0

func (f DrainFailure) Unwrap() error

Unwrap returns the step's own error.

type DrainOptions added in v0.2.0

type DrainOptions struct {
	// Grace is the platform's termination grace; Run also bounds Stop by it.
	//
	// SIZE THE PLATFORM'S TERMINATION GRACE ABOVE Grace + 2×IdleBoundary.
	// Since v0.17.0 a runtime whose release the drain saw refused (a session
	// awaiting a gate spends the whole Grace being refused) is then abandoned
	// under a fresh budget of one IdleBoundary, waited up to one more if the
	// abandon ignores its cancellation, so Stop can run that much past Grace.
	Grace time.Duration
	// IdleBoundary bounds the wait for a session to reach a safe boundary, and
	// the crash-equivalent abandon of a runtime whose release was refused.
	IdleBoundary time.Duration
	// PublishBound bounds the nonaccepting publication.
	PublishBound time.Duration
}

DrainOptions bound the drain.

type DrainReport added in v0.2.0

type DrainReport struct {
	Generation uint64
	State      sessionwire.HostLinkDrainState
	Failures   []DrainFailure

	// Abandoned are the sessions whose runtime refused a graceful release —
	// a session awaiting a gate is never idle, so harness refuses it — and
	// was then given up crash-equivalently: torn down, its journal lease
	// released, nothing durable written. An open gate is preserved, and a
	// successor (in this process or another) restores the session and can
	// answer it. Since v0.17.0.
	Abandoned []DrainSession

	// Parked are the sessions whose runtime refused its release and could not
	// be abandoned (it offers no department.PersistenceFaults, or the abandon
	// failed or exceeded its bound). Such a runtime keeps running and holds its
	// journal lease until the process exits, so a process that stays alive
	// after Stop cannot restore these sessions and should treat a non-empty
	// Parked as a leak. The abandon failure, if any, is also in Failures.
	//
	// PARKED IS A SNAPSHOT AT REPORT TIME. A teardown that was still in flight
	// when the drain gave up waiting keeps running; if it later succeeds, the
	// session context is cancelled then and the success is logged, but this
	// report is not revised.
	Parked []DrainSession
}

DrainReport is what Stop reports: the drain's generation and terminal state and every step that did not succeed. A drained Host with failures IS drained; the failures are what an operator must reconcile, and Core's two-valued drain state cannot carry them over HostLink.

type DrainSession added in v0.17.0

type DrainSession struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

DrainSession names one session a DrainReport lists.

type EvidenceKey added in v0.2.0

type EvidenceKey struct {
	TenantID         sessionwire.TenantID
	StorageBindingID string
}

EvidenceKey names the journal store that serves one tenant's sessions under one storage binding. The tenant is half of the key on purpose: a harness Store files ONE tenant's sessions and refuses a request naming another, so a table keyed on the binding alone could route tenant-b's settlement into tenant-a's journal — refused correctly, and only after the dispatch.

type Host

type Host struct {
	// contains filtered or unexported fields
}

Host is the tenant-scoped Department runtime host.

It holds a RESOLVED configuration: every option was validated once at construction and nothing here changes afterwards. Residency, command consumption, HostLink and drain are separate concerns; what this type fixes is that a Host which exists is a Host whose configuration was coherent.

The fields are unexported and the accessors return values. Nothing mutable is handed out, and nothing the caller still holds can reach in: Options is taken BY VALUE, so a caller mutating its own copy afterwards changes nothing here. Department and the collaborator interfaces are shared by design — Department is immutable by construction, and a copied SessionStore would be a different SessionStore.

SINCE v0.2.0 THE RESOLVED CONFIGURATION LIVES IN internal/hostconfig AND THIS TYPE CARRIES IT. Every package under internal/ consumes the configuration, and this package now also exports the composition that runs it (Compose), which imports those packages; a leaf both sides import cannot live in either, so it moved down and this type became the exported carrier. Nothing a v0.1.0 caller could see has changed: the method set, the Options fields, the error codes and the constants are the same names with the same values, held to the internal ones by test rather than by aliasing — an alias would have changed the exported type's identity, which is a break.

func New

func New(options Options) (*Host, error)

New validates options and returns a Host.

It returns *InvalidOptionsError, carrying a stable OptionErrorCode, for every refusal. A Host with an incoherent configuration is never returned: there is no partially valid Host and no package-global default to fall back on, so a caller that ignores the error has nothing to use. The rules themselves are internal/hostconfig's, and every one of them reaches this constructor; TestNewDelegatesEveryRefusal holds that.

func (*Host) ApplyDeadline

func (h *Host) ApplyDeadline() time.Duration

ApplyDeadline returns the outer bound for applying an admitted command.

func (*Host) Auth

func (h *Host) Auth() AuthVerifier

Auth returns the credential verifier.

func (*Host) Capacity

func (h *Host) Capacity() uint64

Capacity returns how many sessions this Host may hold resident.

func (*Host) ClaimTTL

func (h *Host) ClaimTTL() time.Duration

ClaimTTL returns how long a command claim is held.

func (*Host) Clock

func (h *Host) Clock() Clock

Clock returns the time source.

func (*Host) CommandQueueSize

func (h *Host) CommandQueueSize() int

CommandQueueSize returns the inbound command buffer size.

func (*Host) Department

func (h *Host) Department() *department.Department

Department returns the immutable set of launch targets this Host serves.

func (*Host) FixedSessionID

func (h *Host) FixedSessionID() sessionwire.SessionID

FixedSessionID returns the session a dedicated Host is bound to, or empty for a pooled Host.

func (*Host) ID

func (h *Host) ID() sessionwire.HostID

ID returns the Host's identity.

func (*Host) InternalEndpoint

func (h *Host) InternalEndpoint() sessionwire.InternalEndpoint

InternalEndpoint returns the HostLink base address this Host advertises. Factory dials sessionwire.HostLinkEndpoint(base, tenant), never the base.

func (*Host) IsolationClass

func (h *Host) IsolationClass() sessionwire.HostIsolationClass

IsolationClass returns the advertised cross-tenant boundary, which is also this Host's pooled admission rule. There is no TenantID accessor beside it: see Options.IsolationClass for H8.

func (*Host) Placement

func (h *Host) Placement() sessionwire.HostPlacement

Placement returns the admission model this Host runs under.

func (*Host) ReconcileBatch

func (h *Host) ReconcileBatch() int

ReconcileBatch returns the bound on one reconciliation pass.

func (*Host) ReconcileInterval

func (h *Host) ReconcileInterval() time.Duration

ReconcileInterval returns how often reconciliation runs.

func (*Host) RegistryExpiry

func (h *Host) RegistryExpiry() time.Duration

RegistryExpiry returns how long an advertisement survives without a refresh.

func (*Host) RegistryHeartbeat

func (h *Host) RegistryHeartbeat() time.Duration

RegistryHeartbeat returns the advertisement refresh interval.

func (*Host) SessionStore

func (h *Host) SessionStore() SessionStore

SessionStore returns the durable session state collaborator.

func (*Host) WarmTTL

func (h *Host) WarmTTL() time.Duration

WarmTTL returns how long a released session stays warm before drain.

func (*Host) Workspaces

func (h *Host) Workspaces() WorkspaceProvider

Workspaces returns the workspace collaborator. It does not checkpoint; see WorkspaceProvider.

type InvalidCompositionError added in v0.2.0

type InvalidCompositionError struct {
	Field  string
	Reason string
	Cause  error
}

InvalidCompositionError reports a Composition that may not run.

func (*InvalidCompositionError) Error added in v0.2.0

func (e *InvalidCompositionError) Error() string

func (*InvalidCompositionError) Unwrap added in v0.2.0

func (e *InvalidCompositionError) Unwrap() error

Unwrap returns the lower layer's error, if any.

type InvalidOptionsError

type InvalidOptionsError struct {
	Code   OptionErrorCode
	Field  string
	Reason string
	Cause  error
}

InvalidOptionsError reports an option Host may not run with.

Code is what a caller branches on, Field names the option, Reason is for a person, and Cause carries the typed error a lower layer produced — Core's *RequestValidationError for an endpoint, for instance — so errors.As reaches it instead of a caller substring-matching a sentence.

func (*InvalidOptionsError) Error

func (e *InvalidOptionsError) Error() string

func (*InvalidOptionsError) Unwrap

func (e *InvalidOptionsError) Unwrap() error

Unwrap returns the typed error this one was built from, if any.

type LinkOptions added in v0.2.0

type LinkOptions struct {
	// PingInterval and PongTimeout are the HostLink heartbeat. Optional and
	// paired: one without the other is refused.
	PingInterval time.Duration
	PongTimeout  time.Duration

	// MaxBindingsPerLink and MaxBindings bound what one link and one tenant's
	// routing table may hold. MaxTenantLinks bounds how many per-tenant
	// transports this Host allocates at once; it is a memory bound and not a
	// tenant allowlist.
	MaxBindingsPerLink int
	MaxBindings        int
	MaxTenantLinks     int
}

LinkOptions bound the HostLink transport.

type ListenOptions added in v0.2.0

type ListenOptions struct {
	// Address is the TCP address to listen on. Ignored when Listener is set.
	Address string

	// Listener is an already-bound listener, for a platform that hands one in
	// or a test that needs to know the port. Run closes it on return.
	Listener net.Listener

	// ReadHeaderTimeout bounds a client's request headers; zero means ten
	// seconds. It is never unbounded: an unbounded header read is a slowloris.
	ReadHeaderTimeout time.Duration
}

ListenOptions is where Run serves.

type LiveTextOptions added in v0.12.0

type LiveTextOptions struct {
	RateBytesPerSecond int
	BurstBytes         int
	FlushInterval      time.Duration
	IncludeReasoning   bool
	IncludeToolSteps   bool
}

LiveTextOptions enables transient text previews. Zero fields use safe defaults. IncludeReasoning also streams visible model reasoning when true. It defaults to false; opaque/redacted provider state and signature-only chunks are never published. Text and reasoning share the same transport budget and limits. Enable only after Factory v0.12.1 or later is deployed; earlier Factory mailboxes charge every frame, and WUI through v0.4.0 discards previews.

IncludeToolSteps also streams each tool call's start and completion. It is opt-in and defaults to false. The previews are the tool's own redacted audit summary (never its raw arguments; a tool without one shows only its name) and a result preview of at most 2 KiB, and every viewer of the session sees them — the same content the committed step shows moments later. They share the text budget, are never merged or retried, and a refused one is simply dropped: the committed StepDone supersedes it. They need a runtime implementing department.LiveOptionsSubscriber; a runtime on harness v0.42.0 or later also carries tool_use_id, the key a viewer joins the preview to the committed row on, while an older runtime's frames still render by name.

type NamespaceLayout added in v0.2.0

type NamespaceLayout func(sessionwire.TenantID, sessionwire.SessionID) string

NamespaceLayout answers the durable object prefix a session's runtime writes under. It is a REQUIRED collaborator: the released store publishes no layout accessor and the residency manager refuses a hydration with no namespace in both modes, so a Host composed without one could never attach a session.

type OptionErrorCode

type OptionErrorCode string

OptionErrorCode is a stable, machine-readable reason an option was refused. Reason is free text a caller must not branch on; this is what it branches on.

type Options

type Options struct {
	// HostID identifies this Host to Factory and in HostLink bindings.
	HostID sessionwire.HostID

	// InternalEndpoint is this Host's credential-free HostLink BASE address:
	// a ws or wss scheme and an authority, with no path. Factory derives each
	// tenant's address from it with sessionwire.HostLinkEndpoint(base,
	// tenant), which is base + HostLinkPathPrefix + the escaped tenant. As of
	// v0.3.0 a value HostLinkEndpoint would refuse (including the v0.2.1
	// per-tenant spelling ".../hostlink/<tenant>") or one whose authority
	// cannot be dialled is refused by New; see ErrUndiallableEndpoint.
	InternalEndpoint sessionwire.InternalEndpoint

	// IsolationClass is advertised with capacity so Factory can place
	// cross-tenant work correctly, AND IT IS THIS HOST'S POOLED ADMISSION RULE.
	//
	// THERE IS NO TenantID BESIDE IT, and that is human gate H8, answered
	// 2026-09-04: a Host advertising cross_tenant_isolated may hold several
	// tenants resident; one advertising tenant_exclusive is exclusive by
	// PLACEMENT, not by construction. The admission ledger holds the local
	// backstop.
	IsolationClass sessionwire.HostIsolationClass

	// Department is the immutable set of launch targets this Host serves.
	Department *department.Department

	// SessionStore, Workspaces, Clock and Auth are Host's collaborators.
	SessionStore SessionStore
	Workspaces   WorkspaceProvider
	Clock        Clock
	Auth         AuthVerifier

	// Placement is the admission model this Host runs under.
	Placement sessionwire.HostPlacement

	// Capacity is how many sessions this Host may hold resident. Dedicated
	// placement requires exactly one.
	Capacity uint64

	// FixedSessionID binds a dedicated Host to one session for its lifetime. It
	// is REQUIRED for dedicated placement and REJECTED for pooled.
	FixedSessionID sessionwire.SessionID

	// WarmTTL is how long a POOLED session stays resident after it is observed
	// whole-session idle before this Host warm-releases it: nonterminally, so
	// the next command re-places and restores it. A session with a gate open,
	// a turn in flight or durable command work outstanding is never released.
	// A dedicated Host does not warm-release; its controller's drain ends the
	// session.
	WarmTTL time.Duration

	// RegistryHeartbeat and RegistryExpiry are the advertisement lifetime.
	RegistryHeartbeat time.Duration
	RegistryExpiry    time.Duration

	// ClaimTTL and ApplyDeadline bound command application.
	ClaimTTL      time.Duration
	ApplyDeadline time.Duration

	// CommandQueueSize is the inbound command buffer.
	CommandQueueSize int

	// ReconcileInterval and ReconcileBatch bound reconciliation.
	ReconcileInterval time.Duration
	ReconcileBatch    int
}

Options configures a Host. Every field is required unless its documentation says otherwise; nothing here is discovered, and there is no package-global default Host to fall back on.

type PublicJournal added in v0.10.0

type PublicJournal struct {
	// contains filtered or unexported fields
}

PublicJournal is a Host-owned session's runtime journal as a client may see it (finding W1).

A harness public body names the RUNTIME session id on every event and the RUNTIME command id on every event a command caused. Both are private. Host rewrites them on the live tail it relays; Factory serves /journal (and every live-tail repair) by reading the runtime journal DIRECTLY, never through Host, so the same rewrite has to be applied there or /journal hands a browser exactly what the live tail withholds. This is that rewrite, as the reader a Factory JournalResolver returns.

IT IS THE LIVE TAIL'S FUNCTION, NOT A SECOND ONE. Both paths run internal/publicbody.Project over the same stored body with the same identities, so an event reads byte-for-byte the same live and durably:

  • every `session_id` equal to the runtime session id becomes the public one;
  • `cause.command_id` becomes the public command id the client admitted, read here from the journal's own application prefix for that command, or is removed (and an emptied `cause` with it) when the command has no public identity — a machine-originated cause.

Nothing else changes: sequences, event ids, coverage, cursors, member order, and every other byte of every body are the runtime journal's.

It is safe for concurrent use.

func NewPublicJournal added in v0.10.0

func NewPublicJournal(reader RuntimeJournalReader, tenant sessionwire.TenantID, session sessionwire.SessionID, binding sessionstore.SessionBinding) (*PublicJournal, error)

NewPublicJournal binds reader to one Host-owned session: its tenant, its PUBLIC session id, and the durable binding naming its runtime session.

It keeps a command mapping for the life of the value, read incrementally from the journal; a composition that builds one per read pays a scan of the journal's records up to the page each time. PublicJournals keeps them.

func (*PublicJournal) ReadPublicJournal added in v0.10.0

ReadPublicJournal reads one page of the runtime journal and projects every body in it. The request must name the bound tenant and the RUNTIME session, as a Factory JournalResolver's reads do.

type PublicJournals added in v0.10.0

type PublicJournals struct {
	// contains filtered or unexported fields
}

PublicJournals keeps each session's command mapping across reads, so a JournalResolver can answer a PublicJournal per call without re-reading a journal's records from the start every time. It holds at most its capacity of sessions, forgetting the least recently used; a forgotten session costs one re-read, never a wrong answer — a mapping is immutable.

It is safe for concurrent use.

func NewPublicJournals added in v0.10.0

func NewPublicJournals(capacity int) *PublicJournals

NewPublicJournals returns an empty cache holding at most capacity sessions.

func (*PublicJournals) Reader added in v0.10.0

Reader is NewPublicJournal sharing this cache's mapping for the session.

type Registrar

type Registrar interface {
	Register(context.Context) ([]department.Registration, error)
}

Registrar produces the Department registrations one deployment serves.

IT IS CALLED ONCE, AT STARTUP, AND ITS RESULT IS IMMUTABLE. Department.New validates the registrations and fixes them for the life of the process, so a Registrar that answered differently later would be answering a question nobody asks again. It takes a context because building a launch target may require I/O — reading a model catalogue, resolving a runtime build — and a startup that can block must be cancellable.

type RegistrarFunc

type RegistrarFunc func(context.Context) ([]department.Registration, error)

RegistrarFunc adapts a function to Registrar.

func (RegistrarFunc) Register

Register calls f.

type Residency added in v0.2.0

type Residency struct {
	TenantID               sessionwire.TenantID
	SessionID              sessionwire.SessionID
	AgentID                sessionwire.AgentID
	RuntimeCompatibilityID string

	// LeaseEpoch is this Host's residency grant: the value a bind presents.
	LeaseEpoch uint64

	// JournalEpoch is the RUNTIME's grant, read from the launched runtime and
	// never derived from LeaseEpoch; JournalEpochHeld is the half to branch
	// on, since a headless runtime legitimately holds none.
	JournalEpoch     uint64
	JournalEpochHeld bool

	// Attached reports whether THIS call established the residency. False
	// means the session was already resident and this call took nothing.
	Attached bool
}

Residency is what an attach reports.

type RigSessionIDs deprecated added in v0.2.0

RigSessionIDs answers Harness's own identity for a session whose durable record carries NO BINDING (a legacy record), and is consulted for nothing else.

Deprecated: As of v0.3.0 the Harness identity of every bound session is read from its immutable durable binding (SessionBinding.RuntimeSessionID), which is where Factory records the identity it derived at create. That value is the authority — settlement reads the runtime's journal by it — and a second answer from a composition could only agree with it or name a journal nothing else reads. The collaborator is optional and is kept only so a v0.2.x composition still compiles; no session this workspace ships can be hosted without a binding. A legacy record's journal is never probed, so a create over one is now refused (v0.2.1 launched it).

type RuntimeJournalReader added in v0.10.0

RuntimeJournalReader is what PublicJournal reads a Host-owned session's runtime journal through: the public page it projects, and the privileged record read it takes the command mapping from. A *sessionstore.Store opened over the runtime's backend — the reader a Factory JournalResolver already returns — satisfies it.

type RuntimeProfile added in v0.16.0

type RuntimeProfile uint8

RuntimeProfile is how strictly a composition holds its launch targets to the recovery a durable deployment needs.

THE ZERO VALUE IS DURABLE, AND THAT IS A BEHAVIOUR CHANGE IN host v0.16.0. Under RuntimeProfileDurable, Compose refuses any registration whose target does not declare both department.Recovery capabilities, because a runtime without them fails silently and late: a failover with an attempt in flight wedges the session's command stream for good (no AttemptCloser), and a latched journal fault leaves the session resident and refusing every command (no PersistenceFaults). A composition that never declared recovery — every one built before v0.16.0 — is therefore refused until it declares (harnessruntime.Target does so by construction) or opts out.

The best-effort profile is that opt-out, for fakes, experiments and runtimes that genuinely cannot recover. It admits undeclared targets and is logged at WARN once, on Start. It changes nothing a target DOES declare: a NewRigTarget still refuses, at launch, a session missing a capability its target declared.

const (
	// RuntimeProfileDurable refuses targets that do not declare full recovery.
	// It is the zero value and the default.
	RuntimeProfileDurable RuntimeProfile = iota

	// RuntimeProfileBestEffort admits targets without declared recovery.
	RuntimeProfileBestEffort
)

func (RuntimeProfile) String added in v0.16.0

func (p RuntimeProfile) String() string

String names the profile.

type Service added in v0.2.0

type Service struct {
	// contains filtered or unexported fields
}

Service is one composed Host: built, then started, then stopped.

func Compose added in v0.2.0

func Compose(ctx context.Context, blueprint Composition) (*Service, error)

Compose validates the composition, opens the session store, builds the Department and every collaborator, and returns a Service with nothing running.

COMPOSE OPENS THE STORE ITSELF, and that is the whole reason Collaborators carries a Backend and not a *sessionstore.Store. Settlement refuses without a configured evidence reader, and the released store publishes no way to ask an open one whether it has one; a deployment that opened the store and forgot the reader would attach, list, claim, dispatch, and fail at every settlement, leaving each command applying with a real effect behind it. Opening here means the router built from JournalStores is installed on every store this composition can run on, so that failure is unrepresentable rather than documented. Compose closes the store on any later refusal.

func (*Service) Attach added in v0.2.0

func (s *Service) Attach(ctx context.Context, request AttachRequest) (Residency, error)

Attach makes a session resident on this Host, in process.

It is the SAME entry point the hostlink.attach RPC reaches, so its semantics are the RPC's: serialized per session, idempotent on a resident session (Attached false, the existing epoch), refused with a HostLink class where placement should be re-run and with none where it should not. Attach requires a successful Start; it returns ErrServiceNotStarted before that and ErrServiceDisposed after disposal begins.

func (*Service) CloseUnstarted added in v0.6.0

func (s *Service) CloseUnstarted(ctx context.Context) error

CloseUnstarted disposes a Service that never started and holds no residency. It closes the SessionStore opened by Compose, but leaves the caller's Storage backend open. If initial publication committed before Start returned an error, its advertisement remains until expiry; this is not a graceful drain. Caller cancellation only ends that call's wait. Later calls wait for the same cleanup and return its final result. Start and Stop refuse after disposal.

func (*Service) Department added in v0.2.0

func (s *Service) Department() *department.Department

Department is the immutable set of launch targets this Service serves.

func (*Service) Handler added in v0.2.0

func (s *Service) Handler() http.Handler

Handler serves HostLink at HostLinkPathPrefix + the tenant.

func (*Service) Host added in v0.2.0

func (s *Service) Host() *Host

Host is the resolved configuration this Service runs.

func (*Service) Live added in v0.2.0

func (s *Service) Live() bool

Live reports whether this process should keep running: the liveness probe's answer. It stays true throughout and after a drain and turns false only when Stop has run, so a platform never kills a Host mid-checkpoint or before it has answered `drained` over HostLink.

func (*Service) MetricsHandler added in v0.2.0

func (s *Service) MetricsHandler() http.Handler

MetricsHandler serves this Host's Prometheus series, and only this Host's: the registry behind it holds one collector rather than the default registry's process, Go and dependency series. It is a handler and not a prometheus.Collector so that client_golang's types stay out of this API.

func (*Service) Ready added in v0.2.0

func (s *Service) Ready() bool

Ready reports whether this Host is ACCEPTING: the readiness probe's answer. A draining Host is not ready from the instant its ledger flips.

func (*Service) Routes added in v0.2.0

func (s *Service) Routes() http.Handler

Routes serves HostLink, the two probes and the metrics on one handler: HostLinkPathPrefix, /readyz (Ready), /healthz (Live) and /metrics. It is what Run mounts, exported so a binary that owns its listener can mount the same four routes beside its own.

func (*Service) Start added in v0.2.0

func (s *Service) Start(ctx context.Context) error

Start publishes this Host's advertisements and begins its heartbeat. The first publication is synchronous, so a Host whose directory refuses it fails here rather than running invisibly. A Service starts once and returns ErrServiceDisposed after CloseUnstarted begins.

A Service composed under RuntimeProfileBestEffort logs one WARN, on the first Start, naming the targets that declare no recovery.

func (*Service) Stop added in v0.2.0

func (s *Service) Stop(ctx context.Context) (DrainReport, error)

Stop drains this Host, waits for the drain to finish, closes every tenant transport, and then closes the session store Compose opened. The store is last because everything before it may still write. Before a successful Start it returns ErrServiceNotStarted; after disposal it returns ErrServiceDisposed.

type ServiceLifecycleError added in v0.6.0

type ServiceLifecycleError = compose.ServiceLifecycleError

ServiceLifecycleError reports an operation refused by the Host lifecycle.

type SessionStore

type SessionStore interface {
	// LoadSession returns the durable record for a session, or an error the
	// caller can classify.
	LoadSession(context.Context, sessionwire.TenantID, sessionwire.SessionID) ([]byte, error)
}

SessionStore is the durable session state Host reads and writes.

type WarmTimer added in v0.2.0

type WarmTimer interface {
	C() <-chan time.Time
	Stop() bool
	Reset(time.Duration) bool
}

WarmTimer is one session's warm countdown, in time.Timer's own three methods.

type WorkspaceProvider

type WorkspaceProvider interface {
	// EnsureWorkspace makes a workspace available and returns its root.
	EnsureWorkspace(context.Context, sessionwire.TenantID, sessionwire.SessionID) (string, error)
}

WorkspaceProvider materializes session workspaces.

It does NOT checkpoint them; see Checkpointer for that half. Nor does it release them: the composition needs the release half too, which is Workspaces, and it is Workspaces a composition takes.

type Workspaces added in v0.2.0

type Workspaces interface {
	EnsureWorkspace(context.Context, sessionwire.TenantID, sessionwire.SessionID) (string, error)
	// ReleaseWorkspace drops the local materialization. It never deletes
	// durable state.
	ReleaseWorkspace(context.Context, sessionwire.TenantID, sessionwire.SessionID) error
}

Workspaces materializes and releases session workspaces. It is WorkspaceProvider plus the release half the composition's warm release and drain need; a value satisfying it satisfies WorkspaceProvider too.

Directories

Path Synopsis
cmd
host command
Command host runs one headless Department runtime host.
Command host runs one headless Department runtime host.
Package department defines the immutable set of launch targets a Host serves.
Package department defines the immutable set of launch targets a Host serves.
departmenttest
Package departmenttest is test support for products that register a runtime with Host: an exported conformance run that launches one real session through a department.LaunchTarget and checks the contract Host relies on.
Package departmenttest is test support for products that register a runtime with Host: an exported conformance run that launches one real session through a department.LaunchTarget and checks the contract Host relies on.
Package harnessruntime is the Host runtime backed by harness: it binds Host's department seams to the released github.com/looprig/harness rig and session API, so a product registers a harness rig with Host instead of hand-writing the adapter.
Package harnessruntime is the Host runtime backed by harness: it binds Host's department seams to the released github.com/looprig/harness rig and session API, so a product registers a harness rig with Host instead of hand-writing the adapter.
internal
bodyclass
Package bodyclass holds the shared pre-attempt decision about an unreadable stored command body.
Package bodyclass holds the shared pre-attempt decision about an unreadable stored command body.
commands
Applying an admitted command is §10.4's durable protocol around ONE call into a runtime, and this file is that protocol.
Applying an admitted command is §10.4's durable protocol around ONE call into a runtime, and this file is that protocol.
compose
Package compose is Host's composition root: it builds the Department runtime, the residency manager, the command consumers, the live event relay, the per-tenant HostLink servers, the metrics and the drain into one running service, with an explicit start and stop order.
Package compose is Host's composition root: it builds the Department runtime, the residency manager, the command consumers, the live event relay, the per-tenant HostLink servers, the metrics and the drain into one running service, with an explicit start and stop order.
controlbody
Package controlbody strictly reads interrupt and restore request bodies before a dispatch attempt.
Package controlbody strictly reads interrupt and restore request bodies before a dispatch attempt.
createbody
Package createbody reads a create command's private body, which is Core's CreateRequest, and re-presents the session's first message in the shape a Host's block decoder reads.
Package createbody reads a create command's private body, which is Core's CreateRequest, and re-presents the session's first message in the shape a Host's block decoder reads.
gateresponse
Package gateresponse reads a gate_response command's private body, the one rule both of its readers share.
Package gateresponse reads a gate_response command's private body, the one rule both of its readers share.
gates
Package gates publishes a resident session's open gates to the durable gate projection, so a Factory can show every AskUser or permission question to a user and admit the answer.
Package gates publishes a resident session's open gates to the durable gate projection, so a Factory can show every AskUser or permission question to a user and admit the answer.
harnesstest
Package harnesstest is TEST SUPPORT over the released harness runtime: a real harness session store, a real rig with one loop, and a recording inference client that answers every turn.
Package harnesstest is TEST SUPPORT over the released harness runtime: a real harness session store, a real rig with one loop, and a recording inference client that answers every turn.
inputbody
Package inputbody strictly reads a stored Core input request before the dispatch attempt, without rewriting the bytes handed to a product decoder.
Package inputbody strictly reads a stored Core input request before the dispatch attempt, without rewriting the bytes handed to a product decoder.
lifecycle
Package lifecycle owns this Host's drain: the single-owner state machine that stops admission, releases every resident session in order, and reports when the Host is drained.
Package lifecycle owns this Host's drain: the single-owner state machine that stops admission, releases every resident session in order, and reports when the Host is drained.
livetext
Package livetext reads and joins bounded transient text and reasoning bodies.
Package livetext reads and joins bounded transient text and reasoning bodies.
livetool
Package livetool reads and bounds transient tool-step bodies: harness's public projection of ToolCallStarted and ToolCallCompleted.
Package livetool reads and bounds transient tool-step bodies: harness's public projection of ToolCallStarted and ToolCallCompleted.
modfiles
Package modfiles enumerates Go source files owned by this module while excluding structural directories and nested repository or module boundaries.
Package modfiles enumerates Go source files owned by this module while excluding structural directories and nested repository or module boundaries.
publicbody
Package publicbody projects a runtime's committed public event body onto the identities a client may hold (finding W1).
Package publicbody projects a runtime's committed public event body onto the identities a client may hold (finding W1).
realtime/hostlink
Package hostlink owns the authenticated realtime server used by Factory to reach one Host.
Package hostlink owns the authenticated realtime server used by Factory to reach one Host.
registry
Package registry indexes the sessions a Host currently holds resident.
Package registry indexes the sessions a Host currently holds resident.
residency
Package residency creates and restores sessions under the durable session lease, in one fixed order, and releases everything it took when any step of that order fails.
Package residency creates and restores sessions under the durable session lease, in one fixed order, and releases everything it took when any step of that order fails.
service
Package service derives what a Host publishes about itself, and relays what its resident sessions have already committed.
Package service derives what a Host publishes about itself, and relays what its resident sessions have already committed.
sessionstoreadapter
Package sessionstoreadapter binds Host's narrow local store seams to the released github.com/looprig/sessionstore v0.7.0 Store.
Package sessionstoreadapter binds Host's narrow local store seams to the released github.com/looprig/sessionstore v0.7.0 Store.
settlement
Package settlement holds the end-to-end proof that one command APPLIES and SETTLES across the three modules that had to release for it to be possible.
Package settlement holds the end-to-end proof that one command APPLIES and SETTLES across the three modules that had to release for it to be possible.
testkit
Package testkit holds test doubles shared by this module's tests.
Package testkit holds test doubles shared by this module's tests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL