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

host

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 17, 2026 License: Apache-2.0 Imports: 18 Imported by: 2

README

looprig/host

host is Looprig's Department runtime host. A Host is a tenant-scoped 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.

Status

Department, residency, HostLink, warm release and drain are built. internal/sessionstoreadapter binds them to the released github.com/looprig/sessionstore v0.10.0 store, and internal/harnessadapter to github.com/looprig/harness v0.34.0.

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.

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, and none before one either: the only pre-attempt reason to reject is the apply deadline, and §10.4 gives that to Factory's deadline reconciler. RejectDispositionCommand is deliberately not bound.

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

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

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

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

This section is empty.

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 settlement evidence readers, one per
	// (tenant, storage binding). REQUIRED and non-empty: a Host with no reader
	// cannot settle anything and is refused at composition rather than at the
	// first command.
	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 REQUIRED; see RigSessionIDs.
	RigSessionIDs RigSessionIDs
}

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 {
	// 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

	// 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

	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.
	Grace time.Duration
	// IdleBoundary bounds the wait for a session to reach a safe boundary.
	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
}

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 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 address Factory dials for HostLink.

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 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 the credential-free WebSocket address Factory dials.
	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 released session stays warm before drain.
	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 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 added in v0.2.0

RigSessionIDs answers Harness's own identity for a session, which no released record holds. It is REQUIRED, and the reason is narrower than "restores need it": the store adapter refuses a hydration of ANY session that has a catalog record and no answer here, in both modes — and in production every session a Host is asked to attach has one, because Factory creates the record before it places. A Host composed without this could attach only a session nobody had created. The product that recorded the identity is the only thing that can answer; a deployment with no such record answers with an error, which refuses the attach rather than handing Harness a zero identity.

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.

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.

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.

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.
internal
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.
harnessadapter
Package harnessadapter binds Host's department seams to the released github.com/looprig/harness v0.33.0 rig and session API.
Package harnessadapter binds Host's department seams to the released github.com/looprig/harness v0.33.0 rig and session API.
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.
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.
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