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

factory

package module
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Sep 20, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

README

factory

factory is Looprig's public-facing orchestration service. It owns the HTTP and WebSocket surface, authentication and authorization, durable reads, command admission, target selection, Host links, multi-replica reconciliation, local realtime fan-out, and optional UI composition.

Factory imports neither Host nor Harness. Everything the two services exchange is a record from github.com/looprig/core/sessionwire/v1 or github.com/looprig/sessionstore, and that is the whole of the contract between them.

Factory is deliberately in the data path for active clients at the expected 1,000–5,000 connection scale, and its required state is disposable: reconnects and reads recover from SessionStore.

The import boundary is a test

import_boundary_test.go parses every Go file's import block — production and test — and applies five rules:

Dependency Permitted in

| github.com/looprig/host | nowhere | | github.com/looprig/harness | nowhere | | github.com/centrifugal/... | internal/realtime/... | | github.com/looprig/wui | cmd/factory/... | | k8s.io/..., sigs.k8s.io/... | internal/placement/kubernetes/... |

The rules are functions over PARSED import paths; nothing matches against source text, and containment is compared by whole path segments, so internal/realtimefanout is not inside internal/realtime and a second binary beside cmd/factory does not inherit its exemption. The scan fails as vacuous if it finds no files, and TestScanReachesEveryRuleScope builds a fixture module containing each scope so an exemption whose directory does not exist yet is still proven reachable and still proven to reject the location just outside it.

Two companion guards keep that scan honest. TestModuleHasNoUndeclaredNestedBoundary fails on any nested go.mod, .git or vendor below the root — including the .git file that a submodule or worktree writes — because such a directory stops the module-owned walk and makes all five rules silently stop applying to its whole subtree; a declared boundary is scanned in its own right rather than exempted. And module_pin_test.go parses go.mod's grammar to assert there is no replace directive in either spelling — and with tokens unquoted, since go.mod permits require "github.com/looprig/host" v1.0.0 and a split-only parser would skip it silently — and that every Looprig requirement names its exact released version — go mod verify checks content hashes, not version shape, so nothing else in make check would notice. Modules Factory may never import are rejected there before the released-version map is consulted, so the ban does not expire the day one of them ships.

go.mod names exact released versions and contains no replace directive; nothing here is vendored, so GOWORK=off go test ./... verifies the module against the versions it actually pins.

Composition

factory.New takes functional options and returns a validated *Server. An option is applied at most once -- a repeat is an error, not a last-wins -- and an explicit nil dependency is an error rather than a request for the default, because the seams that have defaults are defaulted by their ABSENCE.

The seams a deployer supplies are identity.Verifier, Authorizer, SessionReader, Commands, Directory, the CSRF configuration, and optionally a Clock, a UUIDSource, a session cookie name, a default tenant, limits, a UI, a *slog.Logger (WithLogger) and the PendingCommands query that triggers pooled placement (WithPendingCommands). PlacementController is no longer required and nothing reads it; the option is still accepted so an existing composition keeps composing. Authentication is supplied as a VERIFIER and not as an authenticator: Factory composes exactly one authenticator, because which credential authenticated a request must have one answer and a second one is an origin guard that skips its CSRF rules silently. The UI is optional in both shapes: WithUIHandler for a handler and WithUIFS for a static bundle, mutually exclusive, and a composition with neither is valid and is exercised as such -- the default binary mounts the Vite bundle, and an embedder that mounts its own or none composes the same Server. Application-owned /ui/ routes can be mounted with WithUIRoutes. Factory authenticates each request, applies its origin and CSRF guard, and calls the required route authorizer before the handler. These routes are independent of the public bundle, so a deployment can provide either, both, or neither. The protected handler reads the same verified principal passed to authorization through UIRoutePrincipal(request).

Serving

Server.Handler is Factory's public HTTP surface: the API under /v1, protected application routes under /ui/ when supplied, and the injected user interface on other paths. The injected UI is handed to the router as its fallback rather than mounted above or beside it, which is what makes API routes take precedence over the SPA: the router splits by path before authentication, so /v1/unknown is a JSON route_not_found, never index.html.

Serve and Stop are the OPTIONAL half. An embedder that owns its own http.Server uses Handler and never calls them. A deployment that wants Factory to own the server passes a listener it opened itself -- which address, which network, whether a supervisor passed the descriptor in, whether TLS is terminated here -- and gets the wire-level bounds HTTPLimits carries: ReadHeaderTimeout, IdleTimeout and MaxHeaderBytes. There is deliberately no ReadTimeout and no WriteTimeout; both are absolute per-connection deadlines, and this surface streams object bodies and will carry a WebSocket. Stop is idempotent, does not return until what it stopped has stopped, and stops public admission first so nothing new is admitted while the background components -- when they exist -- are torn down. It never touches a Host runtime: a session outlives every Factory replica.

What New composes today is that surface and nothing else -- the authenticator built from the deployer's verifier, the origin and CSRF guard, the router and the optional UI. Command admission, placement, the ClientLink and HostLink engines and the multi-replica reconcilers are separate tasks; the seams they will use are validated at composition and then held unread. The router is built with an empty launch Department, a nil object policy and no object-store resolver, each of which fails closed.

Each public seam is the union of the narrow interfaces the packages that CALL it declare for themselves, in internal/httpapi, internal/admission, internal/identity and internal/realtime/clientlink. There is no shared dependency package. The two sets are held together by tests rather than by discipline, and the two directions fail differently: widening a CONSUMER's interface still compiles and fails TestPublicSeamsSatisfyTheirConsumers, while widening a PUBLIC seam past its consumers fails the build outright, because the test fake that implements every seam no longer does.

The public seams name only standard-library, Core, SessionStore and factory/identity types, since a deployer outside this module cannot import github.com/looprig/factory/internal/... to name anything else -- which is why Principal and the CSRF configuration are in the public identity package in the first place. internal/realtime/hostlink's dialer is deliberately NOT on that surface: the HostLink protocol is internal, and what composition configures is the pool's limits.

Identity

internal/identity derives a principal from an inbound credential: an Authorization: Bearer header or the session cookie for REST, a connect token for a ClientLink. Two properties are the reason it is a package rather than a handler:

  • A tenant comes only from verified claims or from configuration. Nothing in the derivation reads the URL, the path, a path value, the body, a form or the Host header, so a conflicting tenant in a request is not weighed and discarded -- it is never read. TestTheDerivationNamesNoTenantCarrier holds that over the PARSED file, because a behavioural sweep can only cover the carriers somebody thought of.
  • An Authenticator holds no per-process state. Every decision is a function of the credential, the injected Verifier and the injected Clock, so a browser whose WebSocket reconnects to another Factory replica is authenticated there with no handoff. That is section 10 of the spec, and it is the same requirement that makes WithCSRF mandatory.

An OperationContext carries exactly a Principal, which credential authenticated the operation, and a W3C trace identifier; the field set is enumerated by test, so a header, a cookie or a raw token cannot be added to it quietly. The credential source is there because CSRF applies to an ambient credential and not to a bearer one, and Authenticator.CredentialSource is the single reader of that precedence rule -- a CSRF guard that re-derived it could disagree, and would do so by skipping CSRF rather than by failing.

For the same reason the operation context is built by a method, Authenticator.NewOperationContext, which derives the source rather than accepting it. Taking it as a parameter moved "which credential authenticated this" back into the edge's hands one call further out, and a caller that wrote SourceBearer for a cookie-authenticated operation would make the CSRF guard skip. NewOperationContextWithSource remains for a caller that authenticated by a route this package does not model, and is documented as the exception rather than the default.

A Credential redacts under %v, %s, %q, %#v, log/slog and encoding/json, because the likeliest way a token reaches a log is that somebody formatted the value they were handed. Those are not independent mechanisms, and the difference was measured: deleting GoString or MarshalJSON changes what %#v and encoding/json produce, while deleting LogValue changes nothing a slog.TextHandler prints, because its KindAny path falls back to fmt and reaches String. LogValue is kept for the handlers that reflect instead, and is held by a runtime slog.LogValuer assertion rather than by a rendering.

Scrubbing a verifier's error text is byte-for-byte replacement, so it removes a verbatim credential and nothing else. A verifier that embeds a prefix, a digest or a re-encoding leaks that derivative, and TestScrubbingCoversOnlyAVerbatimCredential pins the boundary from both sides rather than leaving it to a caveat.

Realtime transport

The realtime transport is pinned exactly. The pins below, the constants in internal/realtime/transport/doc.go and go.mod are held equal to one another by tests in that package -- including this table, which is read from this file, so it cannot drift into a fourth unguarded copy of the version triple:

Role Module / package Version
Embedded server, for ClientLink github.com/centrifugal/centrifuge v0.38.0
Go client, for HostLink github.com/centrifugal/centrifuge-go v0.12.0
Wire library both depend on github.com/centrifugal/protocol v0.19.2
Browser client npm centrifuge 5.7.2

The Go client is deliberately not the latest, and cannot be. Factory embeds the server for ClientLink and dials with the client for HostLink in one binary, so both resolve ONE centrifugal/protocol version. The compatibility test is executable, and every version below was built and run rather than inferred from a go.mod require line -- a require is a floor, not a statement about which symbols a package exports:

centrifuge-go resolved protocol go build ./...
v0.10.12 v0.17.0 exit 0
v0.11.0 v0.19.2 exit 0
v0.12.0 v0.19.2 exit 0 (pinned)
v0.12.1 v0.21.0 exit 1 -- undefined: protocol.GetStreamCommandDecoder

protocol v0.19.2 still exports GetStreamCommandDecoder alongside its replacement; only v0.21.0 drops it. So the break is at centrifuge-go v0.12.1, and v0.12.0 is the newest client that coexists with the pinned server. Reaching v0.12.1 means moving the embedded server to a release built against protocol v0.21.0+, and it is that server move -- not the client move -- that invalidates the compatibility measurement taken against the pinned browser client and requires the spike be redone.

Everything the transport is trusted to do is measured against a real embedded node over a real loopback WebSocket in internal/realtime/transport, including the answers that are inconvenient: a stalled consumer loses its whole connection, not one subscription, by either the connection queue bound or the connection write deadline; and the connect reply carries the ping interval in whole SECONDS, so a sub-second ping interval is silently not negotiated and the server then closes healthy connections for a missing pong. The 5,000-connection transport-only case is behind the transportscale build tag and makes no durability or correctness claim.

History, recovery, presence, join/leave, client publication and WebSocket compression are each asserted off rather than merely left unset. Compression is asserted by speaking the RFC 6455 opening handshake directly from the standard library and reading Sec-WebSocket-Extensions off the 101 response, because the Go client surfaces neither the response nor the negotiated extension; this deliberately adds no direct gorilla/websocket dependency. The Redis and NATS brokers and standalone Centrifugo are absent rather than asserted-refused -- the node keeps its in-process memory broker and no external process is started. The suite exercises the JSON protocol only.

internal/realtime/hostlink is Factory's client side of the Factory-Host connection, split like the ClientLink: a Pool that decides and a CentrifugeDialer that carries. The invariant is one sentence — a session binding never costs a connection, and a connection is never shared between Hosts or between tenants — and the route table is keyed by tenant AND session, because a session id is unique only within its tenant.

One pooled Host serves several tenants at once (Gap 1, v0.5.0). A Host advertises a BASE internal endpoint and serves each tenant's HostLink at Core's sessionwire.HostLinkEndpoint(base, tenant) (base/hostlink/<tenant>, core v0.10.0, host v0.3.0); a verbatim dial of the base is answered 404. The pool derives every dial address in one place from the advertised base and the request's tenant, and keys its links by (HostID, TenantID), so bind, attach, delivery, the live tail and placement all reach a tenant's own connection and nothing of one tenant's crosses another tenant's (R-1). Every per-link structure — capabilities, reconnect state, subscriptions — is per (Host, tenant). HostLinkLimits.MaxLinks therefore bounds (Host, tenant) pairs. A tenant a Host's base cannot address (too_long, unroutable_tenant, or a base that is not bare) is refused before any dial as hostlink.ErrNoTenantEndpoint; placement skips that candidate for that tenant and logs a WARN with Core's code, and a routing bind to such an owner is logged and left unbound. Factory makes no drain RPC: drain is the workload controller's, which must derive the same address.

Compatibility window. A Host advertising a non-bare endpoint (a v0.2.x configuration naming …/hostlink/<tenant>) is refused as base_names_tenant for every tenant, so factory v0.5.0 cannot reach it until it is reconfigured to a bare base; a v0.3.0 Host is reachable only from factory ≥ v0.5.0.

A failed command RPC is reported as ErrCommandUndelivered, which leaves the already committed inbox record pending. A Host's own answer arrives as *HostRefusal carrying Core's typed HostLinkError and is deliberately not in that class, so a caller tells "never seen" from "answered" without reading message text. The package imports no store and a parsed-import test holds that, so it could not record anything about the record either way.

Several Factory replicas may hold their own connection to one Host. There is no broker and no leader: closing one replica's pool leaves the others' connections and routes untouched, measured over fakes and over real sockets.

Core owns the HostLink framing contract, not only the record bodies. Core v0.9.1's (pinned: v0.11.0) sessionwire/v1 defines the bare connect codecs, reserved method names, and injective HostLinkChannel derivation that Factory and Host must share. The asynchronous {type, data} push envelope is the only framing still local to Factory; it is not a Core record or a HostLink RPC method. The Host half does not exist in this repository, so these tests still run against a stand-in node implementing the proposal. They pin Factory's side, but are not proof of a live cross-module Host implementation.

Three defects kept every Factory before the v0.2.0 release from ever holding a link to a Host, and the stand-ins hid all three. Host decoded the connect Data as a bare VersionNegotiationRequest while Factory wrapped it (B8; Core v0.9.0 now owns the connect codecs); a Host's bare refusal body was decoded through a wrapped {"error":…} shape, so it decoded to Error == nil and a genuine refusal was returned as success — fixed silently in 563f15e and recorded here; and the WebSocket upgrade named no subprotocol, which a Host answers HTTP 400 before reading a frame (Sec-WebSocket-Protocol: centrifuge-json is the only route open, since Core's InternalEndpoint refuses the ?format=json query). Every stand-in now gates the upgrade as a Host does, and the dialer was driven against the released host v0.2.1 — dial, Bind and DeliverCommand round-trip — in a gate-side harness that cannot live in this suite because host may not be a Factory dependency.

Bind and unbind are gated by the capability set the Host advertised in its connect reply and refused locally with *UnsupportedMethodError otherwise; during a reconnect, before the new reply has landed, they are refused with the distinct transient ErrLinkReconnecting instead. Command delivery is not gated and is queued by the transport across a reconnect — and emitted before the new reply is verified, because centrifuge-go resolves connect futures before it runs OnConnected. RPCs on a link are concurrent: nothing is held across the RPC, which runs on its own goroutine so that a centrifuge-go v0.12.0 double-callback wedge costs one leaked goroutine rather than a caller.

The pool carries the control plane and, since v0.4.0, a session's live tail: a subscribe to the session's HostLinkChannel made after a successful bind on the same connection. The per-binding queues and the backpressure repair sit above it, in internal/realtime/delivery and routing.Relay. Choosing which Host a session belongs to is A7.2.

Live output (Gap 3, v0.4.0)

A watched session's live output reaches the viewers watching it. internal/realtime/livetail composes routing.Relay between the HostLink subscription and the ClientLink: each record a Host publishes is published once to session:{tenant}:{session}, the channel wui subscribes to, in order. A Host keeps no history, so every time a session's tail starts (other than inside the first viewer's own subscribe) or stops unasked, the viewers receive a session.reset -- sent after the new tail is live -- and repair from the durable journal. On a HostLink reconnect Factory withdraws every tail itself (centrifuge-go's automatic resubscribe would reach a Host before the re-bind), re-binds on the new connection, then subscribes, then resets. Limits: a private record between public ones makes resets name an older sequence than necessary (over-repair), and Host silence while bound still looks like idle. factory.New composes the pool, and Start drives the reaper on the sweep cadence.

Placement

internal/placement decides where a session runs. Decide is pure — a catalog record, an optional registry observation and one capacity page in, one of five outcomes out — and the reconciler around it takes the session's short-lived reconciliation claim, stores desired state idempotently and hands a dedicated session's intent to a WorkloadController.

A live owner is preferred before any claim is taken, and a replica that loses the claim re-reads the registry rather than answering "busy": the winner may have finished placing in between. ReusableOwner is the module's one statement of "is this the session's own live owner"; internal/admission calls it.

Desired state is idempotent twice over. A content comparison keeps a replica from rewriting an intent already stored, which is what holds the desired generation still, and a key derived from the intent keeps a second replica from applying it again — two Factories deriving one intent derive one key, and SessionStore checks the key before the revision.

WorkloadController names no platform type and exposes the four domain lifecycle operations EnsureWorkload, ObserveWorkload, RequestDrain, and DeleteWorkload, all over Core and SessionStore records. The seam is supplemented by optional WorkloadEndpointDiscovery for the first attach: ObserveWorkload reads a post-attach registry observation and cannot discover the first Host address. After ensuring a dedicated workload, Factory asks this method for the ready Host's identity, generation and bare internal endpoint, then attaches through HostLink and binds from the Host's successful residency reply. An older controller lacking discovery yields the internal diagnostic placement.ErrWorkloadEndpointUnsupported; the separate controller module must implement discovery before it can place new dedicated sessions. The endpoint alone proves neither capacity nor residency.

The workload seam remains composition-only here: no scheduled reconciliation driver is built in this module. H5 keeps the Kubernetes adapter out of this module entirely: it lives in the separate repository looprig/controller (owner ruling 2026-09-18), which consumes Factory only as a published module, so no Factory consumer inherits the Kubernetes client graph; cmd/factory supplies no workload create/delete RBAC, so a nil controller is a supported configuration and a dedicated session reaching the tenant-facing replica is refused by name. Widening this exported method set is a source compatibility break for external implementations. While this module is pre-1.0 (owner ruling 2026-09-18), such a widening ships as a MINOR bump — the v0.x contract every module in this workspace is released under — and the widening that added ObserveWorkload, RequestDrain, and DeleteWorkload as EnsureWorkload's companions rides v0.2.0. Once v1.0.0 is cut, widening it becomes a major release.

Pooled placement attaches (B5). With WithPendingCommands, a "placement" sweep pages one control shard of the disposition inbox per interval and, for each session with an open command and no live owner, asks the first ranked admissible candidate to hostlink.attach -- fenced by that candidate's host_id/host_generation from its capacity report, with the sweep's service identity as actor_id -- then binds with the lease epoch the Host answered and delivers the pending commands as a wake. A candidate that did not advertise hostlink.attach in its connect reply is never sent one: it is excluded and logged by Host id, because an older Host routes the method as a channel and answers runtime_unavailable, indistinguishable from a genuine refusal. epoch_mismatch means the registry was stale: placement re-reads it and retries, bounded with backoff, and never binds with the refusal's current_lease_epoch (the other holder's). Every other coded refusal moves on to the next candidate, and so does a Host's own code-less failure reply (a transport error answer such as centrifuge's ErrorInternal) -- so one Host whose launches always fail cannot block placement on the healthy ones. That reply is not a placement outcome, and it does not mean the Host rolled back (host v0.2.1 also sends it after an incomplete rollback, or when the session is resident but its observation could not be published); moving on is safe because the session lease makes any other candidate refuse epoch_mismatch while that Host still holds it, so no second residency can form. A candidate that cannot be asked at all -- a failed dial, a link between connections, a pool at its link ceiling, or a link made terminal by a wire-version change, which the pool also evicts -- is skipped. Only an AMBIGUOUS failure aborts the attempt: a cancelled or timed-out request, a lost connection, a malformed or mismatched reply, or a closed pool, where the request may have reached the Host and a second attach would be put in flight for nothing; the next sweep retries under the same idempotency key. Re-placement waits are capped at 5s each and jittered, and ReplaceAttempts is capped at 10. A tenant_exclusive pooled advertisement is still never selected, because Factory cannot see which tenants a Host serves and refusing is the only enforcement of specification section 12 available to it.

A replica that places nothing says so at Start. Without WithPendingCommands no session is ever attached to a Host, so Start logs a WARN naming that -- an ERROR when WithPublicCreates is also composed, because that replica admits creates it will never place. Requiring the option there, or deriving it from the create plane, is booked for a later minor.

Every command is a disposition command (Gap 2). Input, interrupt, restore and gate response are admitted into the same DISPOSITION inbox a create is, under the session's own immutable binding, so a Host that holds the session consumes and applies them; before this change they went to the legacy inbox, which no Host can reach. An oversized payload of any kind is stored by reference. A command for a session bound to the legacy protocol is refused runtime_unavailable. A "dispositions" sweep, composed in every composition, rejects a disposition command no Host applied before its apply deadline -- only while it is pending or claimed under a lapsed claim, never once an attempt exists -- and a retry of it then answers rejected / runtime_unavailable. The deadline and placement sweeps each keep a position per shard across passes, so a live command behind more than a pass of rows they must skip is still reached, and a pass that ends with a shard's backlog unread is logged at WARN. Commands accordingly names the disposition admission, retry read, payload upload, rejection and due query in place of AdmitCommand and GetCommand; a *sessionstore.Store satisfies it.

The gate rollout rule is met: sessionstore is pinned at v0.12.0, whose readers accept a gate page a Host wrote on a disposition session (older readers refuse it). TestAHostPublishedDispositionGateIsReadByThePinnedStore writes a gate the way a Host does and reads it through both of Factory's gate readers.

A gate response is delivered only to a Host that can apply one: a Host whose connect reply lists Core's token hostlink.command.gate_response (sessionwire.HostLinkCapabilityGateResponse, core v0.11.0; advertised by host ≥ v0.4.0 in hostlink_methods). The answer comes from ONE predicate, hostlink.GateResponseCapable — exact-name Supports, so a substring or a hostlink.v1.-prefixed spelling is not the capability, and the catalog's LeaseEpoch is never read as one. Three places ask it, over the owner's (or candidate's) link for the session's tenant:

  • Admission. Admitting a gate response IS delivering it — the owner reads it from the durable stream — so a gate response for an owner without the token is refused 409 gate_not_resumable (ErrGateResponseUnsupported) before anything is written.
  • The wake. A gate-response wake is withheld from a bound Host without the token and counted (WithheldGateResponses).
  • Placement. A session with a pending gate response attaches only to a Host with the token. Pooled placement skips incapable candidates and reports them in one WARN per pass, at most once per session every 5 minutes. Dedicated placement may ensure its workload first, then probes the ready endpoint's capability before attach; an incapable or unreachable endpoint stays unattached. With no capable Host, the WHOLE session waits — its inputs and interrupts included — for at most the pending answer's apply deadline (ReconcileLimits.ApplyDeadline, default 5 minutes). The expiry sweep then rejects the answer and the session places normally; a command admitted behind it may expire in that window.
  • Staleness. A capability read uses the link's latest reply and is not fenced to the owner's generation, so it can be one reply stale across a Host restart the link has not yet noticed.
  • Transients. A gate response whose owner cannot be reached (its link reconnecting, the link ceiling full, a failed dial) is answered 503 unavailable, retryable, with nothing written. The capability read dials, if it must, outside the pool's lock.

A gate response is never stored by reference. Every other command larger than the inbox's inline bound (sessionstore.MaxInboxPayloadBytes, 64 KiB of canonical command payload — the ENCODED size, where JSON escaping can make it several times the body sent) is uploaded and admitted by reference; a NEW gate response over it is refused 400 invalid_request (ErrGateResponseTooLarge) before anything is written, because host v0.4.0 blocks a session's whole command stream behind a by-reference gate response until its apply deadline, and every command admitted behind it expires with it. A retry of a gate response already stored still answers from its record.

Mixed fleets: do not run v0.3.0 and v0.4.0 Hosts that serve gating agents together. An answer admitted under a v0.4.0 owner that is then re-placed onto a v0.3.0 Host — or that was already claimed there — sits applying until a v0.4.0 successor settles it not_applied. The placement filter keeps a pending answer off an incapable Host, but it cannot recall one already in flight.

Status

factory.New composes the whole service: identity, the HTTP API, admission, the ClientLink node (started by Start), the HostLink pool, the routing table and demand plane, placement and its sweeps, and -- since v0.4.0 -- the live tail (routing.Relay between the HostLink subscription and the ClientLink). Since v0.5.0 one pooled Host serves several tenants (Gap 1), and a gate response reaches a Host that advertises hostlink.command.gate_response (host ≥ v0.4.0). Still open: the default cmd/factory binary (A9.2).

Documentation

Overview

Package factory is the public-facing orchestration service: HTTP and WebSocket surface, authentication and authorization, durable reads, command admission, target selection and Host links.

Factory imports neither Host nor Harness. It talks to Host exclusively through Core and SessionStore records, which are the cross-service contract; import_boundary_test.go enforces the prohibition and contract.go states the positive half.

Index

Constants

View Source
const DefaultVersion = "factory"

DefaultVersion is the build identity a composition naming none reports.

View Source
const MinClientLinkPingInterval = time.Second

MinClientLinkPingInterval is the shortest ping cadence the ClientLink wire can carry.

It is one second because the connect reply expresses the cadence as a WHOLE NUMBER OF SECONDS -- centrifuge@v0.38.0/client.go:2466 computes res.Ping = uint32(c.pingInterval.Seconds()) -- so every sub-second value arrives as 0. That is not merely imprecise. The Go client assigns c.sendPong = res.Pong INSIDE `if res.Ping > 0` (centrifuge-go@v0.12.0/client.go:1467-1474), so a client told 0 is never asked to pong at all, and the server then closes a perfectly healthy connection with DisconnectNoPong (3012) every pong timeout. The deployment sees a reconnect loop and reads it as a network fault.

Truncation ABOVE the floor is harmless in the other direction and is therefore allowed: an interval of 1500ms is advertised as 1s, which only makes the client's own liveness deadline stricter than the server's cadence, and the client adds MaxServerPingDelay (10s by default, centrifuge-go@v0.12.0/client.go:165-166, 791) on top of it.

Variables

View Source
var (
	// ErrNilOption reports a nil entry in the option slice.
	ErrNilOption = errors.New("factory: nil option")
	// ErrDuplicateOption reports an option supplied more than once.
	ErrDuplicateOption = errors.New("factory: option supplied more than once")
	// ErrNilDependency reports an option carrying a nil interface value.
	ErrNilDependency = errors.New("factory: nil dependency")
	// ErrMissingDependency reports a required seam nothing supplied.
	ErrMissingDependency = errors.New("factory: required seam is missing")
	// ErrConflictingUI reports both UI options supplied at once.
	ErrConflictingUI = errors.New("factory: WithUIHandler and WithUIFS are mutually exclusive")
	// ErrInvalidLimits reports a limit value or relationship this composition
	// refuses.
	ErrInvalidLimits = errors.New("factory: invalid limits")
	// ErrNotAServiceIdentity reports a WithServiceIdentity principal that is
	// not a service identity. See that option for why it is refused here.
	ErrNotAServiceIdentity = errors.New("factory: the sweep identity is not a service principal")
	// ErrEmptyReplicaID reports an empty WithReplicaID.
	ErrEmptyReplicaID = errors.New("factory: the replica identifier is empty")
	// ErrEmptyVersion reports an empty WithVersion.
	ErrEmptyVersion = errors.New("factory: the build version is empty")
	// ErrObjectPolicyWithoutResolver reports an ObjectPolicy composed with no
	// ObjectStoreResolver behind it. See WithObjectPolicy.
	ErrObjectPolicyWithoutResolver = errors.New("factory: WithObjectPolicy requires WithObjectStoreResolver")
	// ErrSessionBindingWithoutResolver reports a SessionBinding composed with
	// no ObjectStoreResolver behind it. See WithSessionBinding.
	ErrSessionBindingWithoutResolver = errors.New("factory: WithSessionBinding requires WithObjectStoreResolver")
	// ErrIncompleteSessionBinding reports a WithSessionBinding missing a
	// member. A binding is immutable after create, so a partial one cannot be
	// completed later.
	ErrIncompleteSessionBinding = errors.New("factory: the session binding names no storage binding or no version")
	// ErrCreatePlaneIncomplete reports exactly one half of the create
	// composition. See WithSessionBinding.
	ErrCreatePlaneIncomplete = errors.New("factory: serving V1 creates requires both WithSessionBinding and WithPublicCreates")
	// ErrInvalidLaunchTemplate reports a configured launch target this
	// composition refuses.
	ErrInvalidLaunchTemplate = errors.New("factory: invalid launch template")
)

Composition errors. Every failure New reports wraps one of these sentinels, so errors.Is classifies any of them.

The CONCRETE type varies and a caller that switches on it must handle three shapes: an *OptionError naming one option, a *MissingSeamsError naming every seam that was not supplied -- the most common failure, and NOT an *OptionError -- and ErrConflictingUI returned bare, since it belongs to two options rather than one.

View Source
var (
	// ErrServerStopped reports a Serve on a Server that has been stopped. A
	// stopped Server is not restartable: its composition is still valid, but
	// the components Stop shut down are not restarted, so a caller that wants
	// to serve again composes a new Server.
	ErrServerStopped = errors.New("factory: server is stopped")

	// ErrAlreadyServing reports a second concurrent Serve. One Server owns at
	// most one listener, because Stop states an ORDER over the components it
	// shuts down and two listeners would leave that order unstated for one of
	// them.
	ErrAlreadyServing = errors.New("factory: server is already serving")

	// ErrAlreadyStarted reports a second Start. Background components are
	// started once, because starting them twice would run two sweep loops per
	// pass and two ClientLink nodes on one composition.
	ErrAlreadyStarted = errors.New("factory: server is already started")
	// ErrServerQuiesced reports a Start or Serve after public admission closed.
	ErrServerQuiesced = errors.New("factory: server is quiesced")
)

Lifecycle errors. Serve reports these; Stop reports only what shutting a component down reported.

View Source
var ErrInvalidDirectoryLimits = routing.ErrInvalidConfig

ErrInvalidDirectoryLimits is the class of every NewStoreDirectory rejection.

View Source
var ErrNoEntropy = errors.New("factory: random source failed")

ErrNoEntropy reports that the random source could not supply the bytes an identifier is made of.

View Source
var ErrNoHintPublisher = errors.New("factory: this replica composes no journal-tip hint publisher")

ErrNoHintPublisher was what a journal-tip hint reported while this module composed no hint publisher. Since v0.4.0 the hint is published to the session's ClientLink channel, the same one the live tail uses, and nothing returns this any more.

Deprecated: nothing returns it; it is kept so a caller comparing against it still compiles.

Functions

func UIRoutePrincipal added in v0.6.0

func UIRoutePrincipal(r *http.Request) (identity.Principal, bool)

UIRoutePrincipal returns the verified principal Factory placed on a request before invoking a protected /ui/ route. The principal is a read-only value; it does not expose the credential source or a reusable authorization grant. A request outside Factory's authenticated handler has no such principal.

Types

type Authorizer

type Authorizer interface {
	AuthorizeSessionList(ctx context.Context, principal identity.Principal) error
	AuthorizeSessionRead(ctx context.Context, principal identity.Principal, session sessionwire.SessionID) error
	AuthorizeObjectRead(ctx context.Context, principal identity.Principal, session sessionwire.SessionID, object sessionwire.ObjectReference) error
	AuthorizeControl(ctx context.Context, principal identity.Principal, session sessionwire.SessionID, kind sessionstore.CommandKind) error
	AuthorizeSubscribe(ctx context.Context, principal identity.Principal, channel string) error
	AuthorizeServiceSweep(ctx context.Context, principal identity.Principal) error
}

Authorizer decides every public operation and the one service operation. A denial must wrap identity.ErrUnauthorized. Any other error is a fault in the authorization dependency rather than a permissions decision.

type Catalog

Catalog is the durable session record: the read every decision is made against, and the desired state placement authors.

CreateCatalogEntry is still required but no Factory path calls it: legacy create is refused before any durable write, and a V1 create is authored through PublicCreates. Narrowing the interface is booked for a minor release, because removing a method from an exported interface breaks callers holding a Catalog.

GetCatalogEntry also appears on SessionReader. That is not a second authority: it is one method on one deployer-supplied object reached through two seams, because internal/admission and internal/placement each declare the read beside a write that SessionReader must not carry. Widening SessionReader instead would put CreateCatalogEntry within reach of every read handler.

type ClientLinkLimits

type ClientLinkLimits struct {
	// MaxConnections bounds concurrent ClientLinks on this replica.
	MaxConnections int

	// MaxChannelsPerConnection bounds the session channels one link may hold.
	//
	// It is configuration rather than an omission because the transport
	// defaults it SILENTLY: centrifuge@v0.38.0/node.go:135-136 sets
	// ClientChannelLimit to 128 when it is zero. One browser link multiplexes
	// every session its user is watching, so a ceiling nobody chose is a
	// ceiling that is discovered by a user hitting it.
	MaxChannelsPerConnection int

	// PerConnectionQueueBytes bounds one connection's outbound queue in BYTES.
	//
	// The unit is not a preference. The only thing that enforces it is
	// centrifuge's ClientQueueMaxSize, which config.go:58-61 defines as "a
	// maximum size of client's message queue in bytes", and A5.1 measured the
	// resulting close as DisconnectSlow (3008). This field previously said
	// "in messages" with a default of 256, which would have configured a
	// 256-BYTE budget and closed essentially every connection on its first
	// event. The name now carries the unit so the two cannot drift again.
	PerConnectionQueueBytes int
	// WriteTimeout bounds one outbound write.
	WriteTimeout time.Duration
	// PingInterval is how often the server pings an idle connection.
	PingInterval time.Duration
	// PongTimeout is how long a ping may go unanswered.
	PongTimeout time.Duration

	// CommandTimeout bounds ONE command RPC's durable admission.
	//
	// It is the only bound such an admission has, and it is configuration
	// rather than a constant because the deployment owns how long its durable
	// plane may take. The transport gives the handler no per-RPC context --
	// centrifuge@v0.38.0's RPCEvent carries a method and a payload and nothing
	// else -- and it dispatches the RPC synchronously on the connection's read
	// loop, so an unbounded admission does not merely hang one command: it
	// holds every other frame on that link and it prevents the replica from
	// draining. See clientlink.Limits.CommandTimeout for the measurement and
	// for the limit of what a deadline can promise.
	CommandTimeout time.Duration

	// DemandReleaseDebounce is how long a session's last local subscription
	// must have been gone before this replica gives its delivery demand back.
	//
	// It is configuration rather than a constant because what it trades is a
	// deployment's: a longer window keeps a HostLink open for a session nobody
	// is watching, and a shorter one makes an ordinary browser reconnect cost a
	// registry read and a rebind. Neither is a correctness failure -- demand is
	// local routing state and never authority. Zero is permitted and means
	// "release as soon as the last subscription is gone".
	DemandReleaseDebounce time.Duration

	// DemandTimeout bounds ONE call into the demand plane, in both directions.
	//
	// The acquire is bounded for the reason CommandTimeout is and against the
	// same mechanism: a subscribe is dispatched on the connection's read loop
	// and the connection context is cancelled when that loop returns, so an
	// in-flight acquire is the very thing keeping it alive. The RELEASE is
	// bounded because it has nothing else: it runs on a timer, after the
	// connection that caused it is typically gone, so there is no request
	// context in existence to inherit a deadline from.
	DemandTimeout time.Duration
}

ClientLinkLimits bounds this replica's local connections and fan-out queues.

func DefaultClientLinkLimits

func DefaultClientLinkLimits() ClientLinkLimits

DefaultClientLinkLimits is the configuration a composition gets if it names none. MaxConnections is sized for the 1,000-5,000 connection scale a Factory replica is expected to hold.

func (ClientLinkLimits) Validate

func (l ClientLinkLimits) Validate() error

Validate reports why these limits may not be used.

type Clock

type Clock interface {
	Now() time.Time
	AfterFunc(d time.Duration, f func()) (stop func() bool)
}

Clock is the time seam.

func SystemClock

func SystemClock() Clock

SystemClock is the default Clock: the standard library's.

type Commands

Commands is the durable command plane. A *sessionstore.Store satisfies it.

ControlShards is on it because the periodic sweeps ask the store how many service-control shards it persisted, on every pass rather than once: the count is a decision of the backend and not a setting of this replica, and a replica caching it would keep sweeping a shard space that had changed.

Every command is admitted into the DISPOSITION family -- the only one a Host can take residency on -- through AdmitDispositionCommand, with GetDispositionCommand as the retry read and PutCommandPayload for a payload too large to store inline. RejectDispositionCommand and ListDueDispositionCommands are the disposition deadline sweep, which rejects a command no Host applied before its apply deadline. RejectCommand and ListDueCommands remain for the LEGACY deadline sweep only, which settles legacy rows a store may already hold; nothing admits into that family.

type Directory

Directory is the observed target directory.

func NewStoreDirectory added in v0.2.0

func NewStoreDirectory(store DirectoryStore, limits DirectoryLimits) (Directory, error)

NewStoreDirectory returns the Directory this module reads a SessionStore through: session ownership from the epoch-fenced registry, and placement candidates from the ranked target index in the store's own capacity order.

It holds no routing state. Absence, expiry and a released tombstone are all "no owner" rather than errors, a lapsed candidate row spends a bounded slice of the reconcile budget and the original page is retried, and the continuation handed back is always the honest continuation of the page the caller received. Limits are validated before any store call; a zero DirectoryLimits is refused rather than defaulted, because a directory that silently took a page size a caller did not name is one whose cost the caller cannot state. Use DefaultDirectoryLimits.

type DirectoryLimits added in v0.2.0

type DirectoryLimits = routing.Limits

DirectoryLimits bounds the store directory's foreground capacity query and the due cleanup it may provoke. It is an alias for the reason ObjectLimits is: one declaration, read by the package that enforces it.

func DefaultDirectoryLimits added in v0.2.0

func DefaultDirectoryLimits() DirectoryLimits

DefaultDirectoryLimits is what a composition naming no directory limits receives: a 32-row candidate page, 32-row reconcile pages, SessionStore's default reconcile page budget, and two cleanup attempts per query.

type DirectoryStore added in v0.2.0

DirectoryStore is the SessionStore surface NewStoreDirectory reads: the per-session registry, the ranked target listing, and the due-row reconcile that a lapsed advertisement provokes. A *sessionstore.Store satisfies it.

The registry read and the capacity listing are separate methods here for the reason internal/routing keeps them separate: it makes turning an advertisement into an ownership claim impossible inside the directory, because nothing on this seam can answer "who owns the session" from a capacity row.

type Gates

type Gates interface {
	ControlShards() int
	ListDueGates(ctx context.Context, req sessionstore.ListDueGatesRequest) (sessionstore.DueGatePage, error)
	RetireGateDeadlineIntent(ctx context.Context, req sessionstore.RetireGateDeadlineIntentRequest) error
}

Gates is the service-control gate-deadline plane the gate sweeper reads and retires.

It carries RetireGateDeadlineIntent and NOTHING that could answer a gate. internal/reconcile's own two seams are narrow for that reason -- OpenGate would resurrect a deadline and ResolveGate is a synthesized resolution the runbook forbids -- and this seam is their exact union, so the composition cannot hand the sweeper a capability its own contract refuses.

type HTTPLimits

type HTTPLimits struct {
	// ReadHeaderTimeout bounds the time a peer may take to send a complete
	// header block. It is the slow-header bound, and it is the one field here
	// with no alternative: nothing above the socket can observe a request that
	// has not finished arriving.
	ReadHeaderTimeout time.Duration

	// IdleTimeout is how long a kept-alive connection may sit between
	// requests. It is set explicitly rather than left to default to
	// ReadTimeout, which is zero here, because that default is "forever".
	IdleTimeout time.Duration

	// MaxHeaderBytes bounds a request's headers. internal/identity bounds the
	// one header it parses on its own, deliberately, "because Factory does not
	// own the http.Server"; under Serve it does, and this is that bound.
	MaxHeaderBytes int
}

HTTPLimits bounds the connections Serve accepts.

These are the http.Server bounds internal/httpapi's RouteLimits documentation defers to the task that builds the server: RouteLimits bounds the WORK a request may do, as a deadline on its context, and nothing there can bound a peer that never finishes sending a header or never reads a response.

They apply to Serve only. A library embedding uses Handler and supplies its own http.Server, and these values say nothing about it.

func DefaultHTTPLimits

func DefaultHTTPLimits() HTTPLimits

DefaultHTTPLimits is what a composition naming no HTTP limits receives.

There is deliberately NO ReadTimeout and NO WriteTimeout, and their absence is a decision rather than an omission. Both are absolute per-connection deadlines that net/http applies without regard to what a handler is doing, and this surface has two shapes they would break: an authorized object body is streamed, and /v1/realtime is a WebSocket upgrade a later task fills in, which by construction outlives any write deadline. What bounds a handler's work instead is httpapi.RouteLimits.RequestTimeout, a deadline on the request context that every dependency call takes.

The residue is stated rather than hidden: a peer that completes its headers and then sends its BODY slowly is bounded by the handler's context deadline and by MaxRequestBytes, not by a socket deadline, so it holds a connection for as long as that deadline allows.

func (HTTPLimits) Validate

func (l HTTPLimits) Validate() error

Validate reports why these limits may not be used.

type HostLinkCredential

type HostLinkCredential interface {
	ServiceToken(ctx context.Context) (string, error)
}

HostLinkCredential supplies the SERVICE identity this replica presents to a Host on every dial and every reconnect.

It is deliberately not identity.Principal: a HostLink carries no end user. The Host authorizes a bind against the session's durable lease, not against a user this connection claims to act for.

type HostLinkLimits

type HostLinkLimits struct {
	// MaxLinks bounds concurrent HostLinks. Since v0.5.0 a Host serves each
	// tenant over its own HostLink, so one link multiplexes every session
	// binding of ONE tenant to one Host, and this bounds (Host, tenant) pairs
	// -- Hosts times the tenants this replica serves on each -- not sessions.
	// A deployment that sized it by Hosts alone should re-size it.
	MaxLinks int
	// DialTimeout bounds one dial.
	DialTimeout time.Duration
	// IdleTimeout is how long a link with no local demand is kept.
	IdleTimeout time.Duration
	// ReconnectMin and ReconnectMax bound the reconnect backoff.
	ReconnectMin time.Duration
	ReconnectMax time.Duration
}

HostLinkLimits bounds this replica's demand-driven HostLink pool.

func DefaultHostLinkLimits

func DefaultHostLinkLimits() HostLinkLimits

DefaultHostLinkLimits is the configuration a composition gets if it names none.

func (HostLinkLimits) Validate

func (l HostLinkLimits) Validate() error

Validate reports why these limits may not be used.

type HostTargets

type HostTargets interface {
	ReconcileHostTargets(ctx context.Context, req sessionstore.ReconcileHostTargetsRequest) (sessionstore.HostTargetReconcileResult, error)
}

HostTargets is the Host advertisement index the record sweeper reconciles.

One method. A due advertisement is a hint about which rows to LOOK at and is never evidence that a Host is gone, so the seam offers no way to read one and act on it directly.

type LaunchTemplate

type LaunchTemplate struct {
	// Key is the launch target's identity: the triple SessionStore files an
	// advertisement under, so a configured template and an advertised one are
	// the same value rather than two spellings of one.
	Key sessionstore.HostTargetKey

	// Capabilities are the presentation strings this deployment publishes.
	Capabilities []string

	// Workload is the platform payload a DEDICATED session's workload is
	// created from. It is required for a dedicated target and must be absent
	// for a pooled one: a pooled session runs on a Host that already exists,
	// so a workload configured for it would be a value nothing could ever
	// create.
	Workload sessionstore.DesiredWorkload
}

LaunchTemplate is one launch target this deployment is configured to offer.

It is a COMPOSITION type and not an alias of the one internal/httpapi publishes, and the extra member is exactly why. httpapi.LaunchTemplate carries identity and nothing else, deliberately: /v1/agents publishes it, and a published launch target that restated a runtime definition would be the competing static agent catalogue the specification forbids Factory to keep. Workload is a DEPLOYMENT's opaque platform payload -- a pod spec, a Nomad job -- which a tenant must never be shown. So the composition holds both halves and PROJECTS the published half; see Server.composeDepartment.

Splitting them this way is what makes the projection the only path: there is no field on the published type for a workload to leak through.

func (LaunchTemplate) Validate

func (t LaunchTemplate) Validate() error

Validate reports why this template may not be used.

It defers the identity half to the published type rather than restating it, so the two cannot drift, and adds only the rule that belongs to the member the published type does not have.

type MissingSeamsError

type MissingSeamsError struct {
	// Options are the option constructors that had to be supplied and were not.
	Options []string
}

MissingSeamsError names every seam a composition failed to supply. It is not an *OptionError, because it belongs to a set of options rather than to one.

All of them are reported at once, and SORTED, for two reasons. A caller with three missing seams is not sent round the build-and-fail loop three times; and the order the seams are checked in acquires no reader, so it stays what it is -- an ordering chosen for readability -- rather than becoming a contract nobody meant to make. The sort is held by a case whose check order and sorted order DIFFER; without one, deleting the sort would change nothing observable and that second claim would quietly stop being true.

func (*MissingSeamsError) Error

func (e *MissingSeamsError) Error() string

func (*MissingSeamsError) Unwrap

func (e *MissingSeamsError) Unwrap() error

type ObjectLimits

type ObjectLimits = httpapi.ObjectLimits

ObjectLimits bounds retained response memory and whole-object verification.

func DefaultObjectLimits

func DefaultObjectLimits() ObjectLimits

DefaultObjectLimits retains at most 1 MiB per request and verifies at most 64 MiB.

type ObjectPolicy

type ObjectPolicy = httpapi.ObjectPolicy

ObjectPolicy authorizes one object reference using trusted committed session evidence, and it is an alias for LaunchTemplate's reason. A denial must wrap identity.ErrUnauthorized; any other error is a policy dependency fault.

A nil policy fails closed at the router: an object read is answered "unavailable" before the catalog summary, before any authorization call and before any reader is reached. That is why supplying one is OPTIONAL here and why supplying one without an ObjectStoreResolver is REFUSED -- see WithObjectPolicy.

type ObjectReader

type ObjectReader = httpapi.ObjectReader

ObjectReader is the neutral read capability a frozen binding resolves to.

type ObjectStoreResolver

type ObjectStoreResolver func(ctx context.Context, binding sessionstore.SessionBinding) (ObjectReader, error)

ObjectStoreResolver consumes a session's full immutable binding and answers the store its objects live in, refusing any configuration this deployment does not know.

The refusal is the point. A binding names a StorageBindingID and a BindingVersion chosen at create and immutable afterwards; a resolver that answered a default for an unknown one would serve one deployment's bytes under another's configuration.

type Option

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

Option configures a Server. An option is applied at most once; supplying one twice is an error rather than a silent last-wins.

func WithAuthorizer

func WithAuthorizer(a Authorizer) Option

WithAuthorizer supplies the authorizer for every public operation.

func WithCSRF

func WithCSRF(cfg identity.CSRFConfig) Option

WithCSRF supplies the origin and CSRF configuration. It has no default.

func WithCatalog

func WithCatalog(c Catalog) Option

WithCatalog supplies the durable session record plane. Required.

func WithClientLinkLimits

func WithClientLinkLimits(l ClientLinkLimits) Option

WithClientLinkLimits replaces the local ClientLink and fan-out queue limits.

func WithClock

func WithClock(clock Clock) Option

WithClock replaces the system clock.

func WithCommands

func WithCommands(cmds Commands) Option

WithCommands supplies the durable command plane.

func WithCredentialVerifier

func WithCredentialVerifier(v identity.Verifier) Option

WithCredentialVerifier supplies the credential verifier.

This is the authentication seam, and it is deliberately NOT an authenticator. Which carrier a credential is read from, which one wins when a request presents two, and what the operation context records about the one that authenticated are decided in exactly one place, because a second answer to "which credential authenticated this request" is an origin guard that skips its CSRF rules silently -- internal/httpapi.GuardConfig.Credentials states that at length. What a deployment genuinely owns is what makes a presented credential valid and what it asserts, which is this interface.

func WithDefaultTenant

func WithDefaultTenant(tenant sessionwire.TenantID) Option

WithDefaultTenant scopes an actor credential that names no tenant.

Leaving it unset requires every credential to name its own tenant, which is the cloud deployment's shape; a single-tenant local composition sets it. It applies to an ACTOR only -- a service credential that named no tenant would otherwise acquire one by configuration.

func WithDepartment

func WithDepartment(templates ...LaunchTemplate) Option

WithDepartment supplies the launch targets this deployment offers.

An EMPTY department is a supported composition and is the default: a Factory serving an existing tenant's durable history needs no launchable agent, and answers /v1/agents with an empty list, which is the truthful answer. Every entry is validated here, so a malformed template is a composition failure an operator sees rather than a request-time decision.

func WithDirectory

func WithDirectory(d Directory) Option

WithDirectory supplies the observed target directory.

func WithGates

func WithGates(g Gates) Option

WithGates supplies the service-control gate-deadline plane. Required.

func WithHTTPLimits

func WithHTTPLimits(l HTTPLimits) Option

WithHTTPLimits replaces the connection limits Serve applies. They bound the socket, so they say nothing about a library embedding that uses Handler and supplies its own http.Server.

func WithHostLinkCredential

func WithHostLinkCredential(c HostLinkCredential) Option

WithHostLinkCredential supplies the service token this replica presents to a Host. Required.

func WithHostLinkLimits

func WithHostLinkLimits(l HostLinkLimits) Option

WithHostLinkLimits replaces the HostLink pool limits.

func WithHostTargets

func WithHostTargets(t HostTargets) Option

WithHostTargets supplies the Host advertisement index. Required.

func WithLogger added in v0.3.0

func WithLogger(l *slog.Logger) Option

WithLogger supplies the logger for this module's operational diagnostics: today, a pooled candidate excluded because it does not advertise hostlink.attach, and a session with open commands that could not be placed. Optional; slog.Default() is used when it is not supplied.

func WithObjectLimits

func WithObjectLimits(l ObjectLimits) Option

WithObjectLimits bounds retained response memory and verification I/O.

func WithObjectPolicy

func WithObjectPolicy(p ObjectPolicy) Option

WithObjectPolicy supplies the committed-evidence policy that authorizes an object reference. Optional; without one every object read answers "unavailable" before any reader is reached.

Supplying it WITHOUT WithObjectStoreResolver is refused by New, and that refusal is the whole reason these are two options rather than one value. The router resolves a store only for a NON-ZERO binding and falls back to the read plane for a zero (legacy) one; a nil policy refuses first and unconditionally, so today the fallback is unreachable. A composition that supplied a policy alone would make it reachable in the same change, with no resolver behind it.

func WithObjectStoreResolver

func WithObjectStoreResolver(r ObjectStoreResolver) Option

WithObjectStoreResolver supplies the binding-to-store resolution an object read uses. See WithObjectPolicy for why the two travel together.

func WithPendingCommands added in v0.3.0

func WithPendingCommands(p PendingCommands) Option

WithPendingCommands supplies the durable query pooled placement is triggered from. Optional.

With it, a "placement" sweep runs on the reconcile interval: one control shard per pass, every session with a command still open and no live owner is attached to the first ranked admissible Host that advertises hostlink.attach and accepts, bound with the epoch that Host answered, and woken with its pending commands. The trigger is the durable inbox rather than admission so that a replica which did not admit a command -- because the one that did has died -- still places the session.

Without it, this replica places nothing: a session is made resident only by another replica composed with it, or by a Host that attaches itself.

func WithPlacementController deprecated

func WithPlacementController(p PlacementController) Option

WithPlacementController supplies a PlacementController. Optional since v0.3.0, and nothing reads the value; see PlacementController.

Deprecated: nothing reads it. See PlacementController.

func WithPublicCreates

func WithPublicCreates(p PublicCreates) Option

WithPublicCreates supplies the durable plane a V1 create admits into. Optional, and it travels with WithSessionBinding: both halves are required before a create is served, and neither implies the other -- a plane with no configured binding has nothing to pin, and a binding with no plane has nowhere to put it.

func WithReconcileLimits

func WithReconcileLimits(l ReconcileLimits) Option

WithReconcileLimits replaces the reconciliation limits.

func WithReplicaID

func WithReplicaID(id string) Option

WithReplicaID names this replica in every claim it takes. Required.

It is not authority -- a claim licenses nothing -- but it must be STABLE for a process: extending one's own claim and taking over a crashed replica's lapsed one are the same store write, and the holder is what tells them apart. It is also what a record sweep releases by, so a replica that reported two holders would leave its own claims behind.

func WithServiceIdentity

func WithServiceIdentity(p identity.Principal) Option

WithServiceIdentity names the principal the cross-tenant sweeps run as.

It is REQUIRED and it is checked for Kind service here, at composition, rather than trusted: every sweep this Server drives calls AuthorizeServiceSweep with this value, and a tenant principal reaching that call is a cross-tenant read authorized as a tenant's.

func WithSessionBinding

func WithSessionBinding(storageBindingID, bindingVersion string) Option

WithSessionBinding supplies the storage configuration every created session is permanently pinned to. Without it a V1 create is refused runtime_unavailable and every other operation is unaffected.

It is REFUSED without WithObjectStoreResolver, for a sharper reason than WithObjectPolicy's. A SessionBinding is IMMUTABLE AFTER CREATE: a session pinned to a StorageBindingID this deployment's resolver cannot resolve has permanently unreadable objects, and there is no repair short of an offline migration. Requiring the resolver does not prove the resolver knows THIS binding -- nothing at composition time can -- but it does refuse the one composition that is certainly wrong, which is pinning sessions to a storage configuration in a deployment that resolves no storage at all.

func WithSessionCookieName

func WithSessionCookieName(name string) Option

WithSessionCookieName replaces the browser session cookie a request is read from when it carries no Authorization header.

A name that is not a valid cookie name is refused by New rather than at the first request: net/http DROPS such a cookie when it writes one, so the composition would look configured and never authenticate anybody.

func WithSessionReader

func WithSessionReader(r SessionReader) Option

WithSessionReader supplies the durable read plane.

func WithUIFS

func WithUIFS(dir fs.FS) Option

WithUIFS mounts a static user interface bundle.

func WithUIHandler

func WithUIHandler(h http.Handler) Option

WithUIHandler mounts a caller-supplied user interface handler.

func WithUIRoutes added in v0.6.0

func WithUIRoutes(h http.Handler, authorize UIRouteAuthorizer) Option

WithUIRoutes mounts application-owned /ui/ routes behind Factory's authentication, origin/CSRF guard, and a required per-request authorizer. The handler can read the verified principal with UIRoutePrincipal. The ordinary UI handler remains a public asset and SPA fallback.

func WithUUIDSource

func WithUUIDSource(u UUIDSource) Option

WithUUIDSource replaces the random identifier source.

func WithVersion

func WithVersion(v string) Option

WithVersion names the Factory build identity reported to a connecting client and to a Host. Optional; DefaultVersion is used when it is not supplied.

func WithWorkloadController

func WithWorkloadController(w WorkloadController) Option

WithWorkloadController supplies the platform adapter a DEDICATED session's workload is created through. Optional, and deliberately so.

cmd/factory composes none: H5 puts the platform adapter in a separate controller binary, whose ServiceAccount holds the workload RBAC that the Factory's does not. A placement reconciler composed without one refuses a dedicated session with a named error instead of reporting success for work nothing did, which is what keeps the split from being a convention.

type OptionError

type OptionError struct {
	// Option is the constructor's name, or the name of the option that had to
	// be supplied and was not.
	Option string
	Err    error
}

OptionError names the option a composition failure belongs to.

func (*OptionError) Error

func (e *OptionError) Error() string

func (*OptionError) Unwrap

func (e *OptionError) Unwrap() error

type PendingCommands added in v0.3.0

type PendingCommands interface {
	ControlShards() int
	ListDueDispositionCommands(ctx context.Context, req sessionstore.ListDueDispositionCommandsRequest) (sessionstore.DispositionDueCommandPage, error)
}

PendingCommands is the disposition inbox's service-plane due query: the durable record of every session with open work. A *sessionstore.Store satisfies it.

It is what TRIGGERS pooled placement. With it, every sweep interval pages one control shard for commands still open, and each session with one and no live owner is attached to a Host and woken; see WithPendingCommands.

type PlacementController deprecated

type PlacementController interface {
	EnsurePlacement(ctx context.Context, desired sessionstore.DesiredWorkload) error
	ReleasePlacement(ctx context.Context, tenant sessionwire.TenantID, session sessionwire.SessionID) error
}

PlacementController is the placement seam this module shipped before it had a placement caller.

It is NO LONGER REQUIRED, and nothing reads it. EnsurePlacement takes a DesiredWorkload, which names no tenant, session or generation, so no implementation could identify what it was asked to place; H5 put the platform adapter behind WorkloadController instead, and pooled placement is driven by this module's own HostLink attach (see WithPendingCommands). The option is still ACCEPTED, so a composition that supplies one keeps composing; removing the type or the option would break that caller for no gain.

Deprecated: nothing reads it. Pooled placement attaches through the HostLink and dedicated placement goes through WorkloadController.

type PublicCreates

type PublicCreates = admission.PublicCreateStore

PublicCreates is the durable public-create plane a V1 create admits into. It is the DISPOSITION family, which a *sessionstore.Store satisfies.

type ReconcileLimits

type ReconcileLimits struct {
	// Interval is the sweep cadence.
	Interval time.Duration
	// ClaimTTL is how long a reconciliation claim suppresses duplicate work.
	ClaimTTL time.Duration
	// ApplyDeadline is how long an accepted command may stay unapplied before
	// it becomes rejected/runtime_unavailable.
	ApplyDeadline time.Duration
	// MaxDuePerSweep bounds the due records one sweep withdraws.
	MaxDuePerSweep int
	// MaxConcurrent bounds concurrent reconciliations in this replica.
	MaxConcurrent int
}

ReconcileLimits bounds Factory's periodic reconciliation of pending commands with no live owner.

func DefaultReconcileLimits

func DefaultReconcileLimits() ReconcileLimits

DefaultReconcileLimits is the configuration a composition gets if it names none.

func (ReconcileLimits) Validate

func (l ReconcileLimits) Validate() error

Validate reports why these limits may not be used.

type Server

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

Server is the composed Factory.

What it composes TODAY is the durable public HTTP surface and nothing else: the credential authenticator built from the deployer's verifier, the origin and CSRF guard, internal/httpapi's router -- routed reads, the authenticated bootstrap and the JSON error envelope -- and the optional user interface, mounted as that router's fallback outside the API version segment.

What it does NOT compose is stated here rather than left to be discovered. Command admission, target placement, the ClientLink and HostLink engines and the multi-replica reconcilers are separate runbook tasks and are not built yet, so the seams they will use are validated at composition and then held unread. The router is composed with an EMPTY launch Department, a nil object policy and no object-store resolver, each of which fails closed: /v1/agents answers an empty list and an object read answers "unavailable" rather than serving bytes no policy authorized.

func New

func New(opts ...Option) (*Server, error)

New validates a composition and returns it.

The checks run in a fixed order -- shape of the option list, then what each option carries, then what is missing, then what the values mean together -- so a caller with more than one defect is told about the outermost one, and every later check is reached only when the earlier ones hold.

func (*Server) CSRF

func (s *Server) CSRF() identity.CSRFConfig

CSRF returns the composed origin and CSRF configuration. The result shares no memory with the Server's copy, so a caller that inspects and then reuses the returned key buffer cannot change what a running Factory signs with.

func (*Server) ClientLinkLimits

func (s *Server) ClientLinkLimits() ClientLinkLimits

ClientLinkLimits returns the composed ClientLink limits.

func (*Server) HTTPLimits

func (s *Server) HTTPLimits() HTTPLimits

HTTPLimits returns the composed HTTP connection limits.

func (*Server) Handler

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

Handler is Factory's public HTTP surface: the API under /v1, protected application routes under /ui/ when supplied, and the injected user interface on other paths when supplied.

It is what a LIBRARY embedding uses. Factory does not own the socket in that shape -- the embedder supplies the http.Server, and MaxHeaderBytes with it -- so nothing here reads a listener. A deployment that wants Factory to own the server calls Serve instead.

The returned handler is the same one on every call and is safe for concurrent use.

func (*Server) HostLinkLimits

func (s *Server) HostLinkLimits() HostLinkLimits

HostLinkLimits returns the composed HostLink pool limits.

func (*Server) Quiesce added in v0.7.0

func (s *Server) Quiesce(ctx context.Context) error

Quiesce permanently closes public admission and waits for every command admission already inside the shared service to return. Sweeps and HostLinks continue running until Stop. Canceling ctx only ends this caller's wait.

func (*Server) ReconcileLimits

func (s *Server) ReconcileLimits() ReconcileLimits

ReconcileLimits returns the composed reconciliation limits.

func (*Server) Serve

func (s *Server) Serve(ln net.Listener) error

Serve accepts connections on ln until Stop, and owns the http.Server it builds -- but not the listener, which the caller opens and which Stop closes by shutting that server down.

The split is deliberate. Opening the socket is where a deployment's real decisions live -- which address, which network, whether systemd or a supervisor passed the descriptor in, whether TLS is terminated here or ahead of here -- and none of them belong to this module. What DOES belong here is the set of bounds a wire-level peer is held to, which is HTTPLimits.

A deliberate Stop is reported as success rather than as http.ErrServerClosed: this method's contract is "serve until stopped", and a caller should not have to classify the ordinary ending. Any other failure is returned as it was.

Serve is optional. An embedder that owns its own http.Server uses Handler and never calls this. # Why the state claim is the FIRST thing this method does

It has to be, and the cost of getting it wrong is a leaked socket rather than a confusing error. Everything Stop can close about the public surface it reaches through s.http, so a Stop that lands while s.http is still nil skips Shutdown entirely. If this method can then return WITHOUT having handed the listener to net/http, nobody closes it: the caller's socket stays open and unserved, which is exactly the failure "Shutdown stops public admission/listener" exists to prevent.

An earlier draft of this stage called Start BEFORE the claim, on the reading that background planes should exist before the network is admitted -- which is right, and the PLACEMENT was wrong. A node boot and three goroutine launches sat between entering Serve and claiming the state, and a Stop in that window made Serve return ErrServerStopped with the listener orphaned. It reproduced roughly one run in three under load.

The general shape, worth carrying: MOVING WORK AHEAD OF A LOCK OR STATE CLAIM WIDENS A WINDOW THAT USED TO BE ZERO. The claim is cheap and it is what makes every later step safe to interleave, so it goes first and the work goes after it.

func (*Server) Start

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

Start runs this replica's background components.

It exists as a separate method from Serve because a LIBRARY embedding owns its own http.Server and reaches the surface through Handler; without Start such a deployment would compose every reconciler and run none of them, and the symptom -- commands that are accepted and never settled -- would appear nowhere near the composition that caused it. Serve calls it, so a deployment that owns the socket through this module does not have to.

The order inside it is stated rather than incidental:

  1. The ClientLink node, because clientlink.NewHandler RUNS it and a connection arriving the instant the listener opens must find a node, not a half-built one. Until this succeeds /v1/realtime answers 503.
  2. The periodic sweeps, each on its own goroutine and its own timer, so one slow pass delays only its own sweep.

The HostLink pool and the routing table are NOT started: both are demand driven, hold no goroutine until a session is bound, and are stopped by Stop whether or not they ever were.

Start is not restartable. A stopped Server refuses it, for Serve's reason: the components Stop shut down are not restarted, so a caller that wants to run again composes a new Server.

func (*Server) Stop

func (s *Server) Stop(ctx context.Context) error

Stop shuts the Server down in a fixed order and does not return until each component it stopped has stopped.

Quiesce first fences durable command admission and closes ClientLinks while leaving sweeps and HostLinks live. Stop then closes the listener and the remaining background components.

There are THREE phases and four components, and each phase is labelled at the code below rather than only here. (This comment said "today there is exactly one component" until A9.1 stage 2 composed the rest; it is stated in full now because a shutdown order that is documented wrongly is worse than one that is not documented at all.)

Stop never touches a Host runtime. Factory is not their supervisor: a session outlives every Factory replica, and a Stop that reached into placement would end sessions because a deployment restarted a front end.

It is idempotent, because a signal handler and a deferred stop reach it together, and it is safe to call on a Server that never served.

func (*Server) UI

func (s *Server) UI() (http.Handler, bool)

UI reports the optional user interface handler.

The second result is false for a library composition that serves no UI, which is a supported configuration rather than a degraded one: the default binary mounts the Vite bundle, and an embedder that mounts its own or none at all composes the same Server.

type SessionBindingTemplate

type SessionBindingTemplate = admission.SessionBindingTemplate

SessionBindingTemplate is the deployment-configuration half of the immutable SessionBinding every session this Factory creates is pinned to.

It names the two members ObjectStoreResolver keys on, and that is not a coincidence -- it is the argument for supplying it here at all. The other two members of a store SessionBinding are not a deployment's to choose: RuntimeSessionID is derived per create from the create's own identity, and ProtocolMode is fixed to disposition because a legacy-bound session is one no Host can take residency on.

type Target

type Target struct {
	Tenant  sessionwire.TenantID
	Session sessionwire.SessionID
	Agent   sessionwire.AgentID
	Host    sessionwire.HostID
}

Target names the Host that a Factory has selected for one tenant's session.

It exists in this scaffold to state the positive half of the import boundary in code rather than in prose: the cross-service contract between Factory and Host is Core's wire vocabulary and SessionStore's durable records, and nothing else. Every member below is a Core identifier, and the only way to build one is from a SessionStore record. There is deliberately no field here that could hold a Host or Harness value, because this module cannot name one.

The composition seams themselves — authenticator, authorizer, session store interface, target directory, HostLink dialer — are task A0.2's.

func TargetFor

func TargetFor(tenant sessionwire.TenantID, session sessionwire.SessionID, target sessionstore.HostTarget) Target

TargetFor projects a SessionStore capacity row into the identifiers a placement decision is recorded with.

The tenant and session are parameters rather than fields read off the row on purpose: a HostTarget is capacity, not authority, and it names no tenant and no session. Ownership is established against SessionStore's registration record and its lease epoch, never inferred from an advertisement.

type TenantAuthorizer added in v0.2.0

type TenantAuthorizer struct{}

TenantAuthorizer is the tenant-boundary Authorizer this module composes for its own tests and the one a deployment with no finer policy uses.

Its zero value is ready to use and it holds no state. Every tenant-scoped decision is "the principal holds a tenant", every channel decision is "the channel's tenant is the principal's", and the one cross-tenant decision -- the service sweep -- requires a service principal. It reads none of the resource identifiers it is handed, because a tenant-local identifier is not authority and consulting it before authorizing would disclose existence; the tenant boundary is then applied by every SessionStore request being built with the authenticated principal's tenant. A denial is identity.ErrUnauthorized and carries no identifier.

It forwards to internal/identity.Authorizer method for method rather than embedding it, so the exported surface names no internal type; TestTenantAuthorizerIsTheInternalAuthorizer holds the two method sets equal.

func (TenantAuthorizer) AuthorizeControl added in v0.2.0

func (TenantAuthorizer) AuthorizeControl(ctx context.Context, principal identity.Principal, session sessionwire.SessionID, kind sessionstore.CommandKind) error

func (TenantAuthorizer) AuthorizeObjectRead added in v0.2.0

func (TenantAuthorizer) AuthorizeObjectRead(ctx context.Context, principal identity.Principal, session sessionwire.SessionID, object sessionwire.ObjectReference) error

func (TenantAuthorizer) AuthorizeServiceSweep added in v0.2.0

func (TenantAuthorizer) AuthorizeServiceSweep(ctx context.Context, principal identity.Principal) error

func (TenantAuthorizer) AuthorizeSessionList added in v0.2.0

func (TenantAuthorizer) AuthorizeSessionList(ctx context.Context, principal identity.Principal) error

func (TenantAuthorizer) AuthorizeSessionRead added in v0.2.0

func (TenantAuthorizer) AuthorizeSessionRead(ctx context.Context, principal identity.Principal, session sessionwire.SessionID) error

func (TenantAuthorizer) AuthorizeSubscribe added in v0.2.0

func (TenantAuthorizer) AuthorizeSubscribe(ctx context.Context, principal identity.Principal, channel string) error

type UIRouteAuthorizer added in v0.6.0

type UIRouteAuthorizer func(context.Context, identity.Principal, string, string) error

UIRouteAuthorizer decides access to one request under /ui/. A nil error grants access; identity.ErrUnauthorized denies it, and other errors are faults. Factory authenticates and applies its origin/CSRF guard before calling it.

type UUIDSource

type UUIDSource interface {
	NewUUID() (string, error)
}

UUIDSource is the random-identifier seam.

func CryptoUUIDSource

func CryptoUUIDSource() UUIDSource

CryptoUUIDSource is the default UUIDSource: version 4 UUIDs from crypto/rand.

type WorkloadController

type WorkloadController interface {
	EnsureWorkload(ctx context.Context, intent sessionstore.PlacementIntent) error
	ObserveWorkload(ctx context.Context, intent sessionstore.PlacementIntent) (sessionwire.HostLinkRegistryObservation, bool, error)
	RequestDrain(ctx context.Context, intent sessionstore.PlacementIntent) (sessionwire.HostLinkDrainObservation, error)
	DeleteWorkload(ctx context.Context, intent sessionstore.PlacementIntent) error
}

WorkloadController owns the lifecycle of a dedicated session's workload.

It is OPTIONAL and only the controller binary supplies it. H5 (answered 2026-09-04) puts the platform adapter in a separate controller, so cmd/factory composes none and a dedicated placement there fails with a named refusal rather than silently doing nothing.

The intent is the whole currency: Factory-authored desire, carrying its own generation and an opaque workload payload this module never parses. The lifecycle observations are Core records, so a Kubernetes PodSpec, a Nomad job and a future platform's manifest remain behind the adapter boundary.

type WorkloadEndpointDiscovery added in v0.6.0

type WorkloadEndpointDiscovery interface {
	WorkloadEndpoint(ctx context.Context, intent sessionstore.PlacementIntent) (sessionwire.HostID, uint64, sessionwire.InternalEndpoint, bool, error)
}

WorkloadEndpointDiscovery is the optional pre-attach half of a dedicated workload controller. It returns the Host ID, desired generation and bare internal endpoint only once the intended workload is ready. The endpoint is a dial target, not a registry observation or proof of residency. Factory checks the generation and Core's bare-base rules; the controller must verify that the Host ID and endpoint belong to the exact intent and that its workload is ready before returning true. HostLink Attach then checks agent, runtime compatibility, session and the Host fence under the Host lease. Factory refuses a dedicated attach when a controller lacks this method; older controllers remain usable for lifecycle operations but cannot place a new dedicated session.

Directories

Path Synopsis
Package identity holds the tenancy vocabulary that Factory's composition seams exchange.
Package identity holds the tenancy vocabulary that Factory's composition seams exchange.
internal
admission
Package admission accepts commands durably and reconciles the ones with no live owner.
Package admission accepts commands durably and reconciles the ones with no live owner.
command
Package command holds the durable command vocabulary Factory admits.
Package command holds the durable command vocabulary Factory admits.
httpapi
Package httpapi serves Factory's public REST plane.
Package httpapi serves Factory's public REST plane.
identity
Package identity derives Factory's principals from inbound credentials.
Package identity derives Factory's principals from inbound credentials.
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.
placement
Package placement decides where a session should run, and reconciles the durable desired state that decision implies.
Package placement decides where a session should run, and reconciles the durable desired state that decision implies.
realtime/clientlink
Package clientlink holds Factory's browser-facing duplex connection.
Package clientlink holds Factory's browser-facing duplex connection.
realtime/delivery
Package delivery holds Factory's bounded record queues, ABOVE the transport.
Package delivery holds Factory's bounded record queues, ABOVE the transport.
realtime/hostlink
Package hostlink holds Factory's client side of the Factory-Host connection.
Package hostlink holds Factory's client side of the Factory-Host connection.
realtime/livetail
Package livetail carries a watched session's live output from the Host that owns it to the ClientLink viewers watching it on this replica.
Package livetail carries a watched session's live output from the Host that owns it to the ClientLink viewers watching it on this replica.
realtime/transport
Package transport is where Factory's realtime transport dependency is pinned and where its behaviour is MEASURED rather than assumed.
Package transport is where Factory's realtime transport dependency is pinned and where its behaviour is MEASURED rather than assumed.
reconcile
Package reconcile retires the durable remnants a crashed control-plane operation leaves behind.
Package reconcile retires the durable remnants a crashed control-plane operation leaves behind.
routing
Package routing reads the durable routing and capacity records that Factory uses to find Hosts.
Package routing reads the durable routing and capacity records that Factory uses to find Hosts.

Jump to

Keyboard shortcuts

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