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

factory

package module
v0.16.1 Latest Latest
Warning

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

Go to latest
Published: Oct 16, 2026 License: Apache-2.0 Imports: 29 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.

Factory is a Go library, not a program: it ships no binary and no UI. A product composes it with factory.New and its own storage, authentication and UI (Carbon and the tests integration lane are the in-workspace compositions).

Status

Released and in use. factory.New composes the whole service:

  • identity (bearer, cookie and ClientLink credentials through an injected identity.Verifier), authorization, and the origin/CSRF guard;
  • the /v1 REST API: bootstrap, agents, the session list, session create, status, journal, gates, objects, and the input, interrupt, restore and gate-response controls;
  • durable command admission through SessionStore's disposition inbox, with deadline and reconciliation sweeps;
  • the ClientLink (browser WebSocket) and the HostLink pool, one link per (Host, tenant), dialled at Core's HostLinkEndpoint;
  • pooled placement (WithPendingCommands) and dedicated placement through an injected WorkloadController with WorkloadEndpointDiscovery, including restoring a released dedicated session;
  • the live tail from a Host to a session's viewers, with session.reset repair and gap detection;
  • reads of a Host session's runtime journal and tool-result objects through WithSessionJournalResolver and WithSessionObjectStoreResolver;
  • ordered shutdown with Server.Quiesce and Server.Stop.

Known limits that matter to a deployer (each is described in its section below):

  • No Prometheus /metrics handler or factory_ series.
  • Drain-before-delete of a dedicated workload is the workload controller's, not Factory's.
  • A cold AskUser answer/resume is unsupported; gate answers need a resident session on a Host that advertises hostlink.command.gate_response.
  • A tenant_exclusive pooled Host advertisement is never selected.
  • WithPlacementController, WithJournalResolver and WithObjectStoreResolver are deprecated.

Compatibility: pair Factory with Host ≥ v0.3.0 advertising a bare HostLink base; gate responses need Host ≥ v0.4.0; dedicated first placement needs a controller implementing WorkloadEndpointDiscovery (looprig/controller ≥ v0.2.0).

Install

go get github.com/looprig/factory@latest

Packages

Package Purpose
github.com/looprig/factory New, options, public seams, Server (Handler, Start, Serve, Quiesce, Stop)
github.com/looprig/factory/identity Public identity vocabulary: Principal, Verifier, Credential, CSRFConfig, ErrUnauthorized, ErrRateLimited, RateLimitedError
internal/httpapi The public REST plane, origin/CSRF guard and error envelope
internal/identity Principal derivation from credentials
internal/admission Durable command admission and deadline reconciliation
internal/command The shared command vocabulary, refusal status and state projection
internal/placement Placement decisions, pooled attach and dedicated workload reconciliation
internal/routing Local binding table, demand-driven binding and the backpressure relay
internal/reconcile Retirement of durable remnants of crashed control-plane operations
internal/realtime/clientlink Browser-facing ClientLink over an embedded Centrifuge node
internal/realtime/hostlink Factory's client side of the Factory-Host connection
internal/realtime/livetail Live output from a Host to a session's viewers
internal/realtime/delivery Bounded record queues above the transport
internal/realtime/transport Realtime transport version pins and measurements
internal/modfiles Module file enumeration used by the guards and make fmt

examples/deploy is a product composition template (a Kubernetes manifest and a separately-moduled wiring package), not a runnable Factory command.

Where it sits

Tier 5 (orchestration). Direct Looprig dependencies: core, sessionstore and storage. Factory never imports host, harness or wui, and no Kubernetes library enters its module graph; the Kubernetes adapter is the separate looprig/controller repository, which depends on Factory.

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 | nowhere | | 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 package beside internal/placement/kubernetes 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 -- Factory ships no UI of its own and no cmd/factory binary exists or is planned. Users bring their own UI: a product supplies its bundle or handler through these seams, and Factory mounts it behind its own auth, origin and CSRF guards. Carbon, for example, supplies wui.Assets() to WithUIHandler; an embedder that mounts its own bundle 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).

A Host-owned session's journal: WithSessionJournalResolver (v0.10.0)

A journal resolver is required with WithPublicCreates or WithPendingCommands; New refuses either without one (ErrHostSessionsWithoutJournalResolver, as an *OptionError naming WithSessionJournalResolver). Use WithSessionJournalResolver; the v0.9.0 WithJournalResolver still works and satisfies the requirement, but is deprecated, and supplying both is refused (ErrConflictingJournalResolvers, as an *OptionError naming WithSessionJournalResolver) rather than one silently shadowing the other. A session a Host runs is DISPOSITION-bound, and its journal is not in the SessionStore you hand WithSessionReader: the runtime keeps it on its own backend under the binding's RuntimeSessionID, and SessionStore never writes a public journal for a disposition session. Read through WithSessionReader alone, every Host session's journal is empty at tip 0, so /journal shows no history and every live-tail repair after a delivered record UNSUBSCRIBES the session's viewers (Core refuses a session.reset whose last_contiguous is above its journal_tip) instead of resetting them. That was the stock composition up to v0.8.x.

With a resolver, every journal read Factory makes -- /v1/sessions/{sid}/journal (and its journal_tip), the journal_tip hint for an unbound watched session, and the tip every live-tail repair resets to -- reads the catalog binding for the public id and, for a disposition-bound session, reads resolver(ctx, tenant, session, binding) under binding.RuntimeSessionID. session is the PUBLIC session id the read is for -- the authorized /journal route's session, or the watched session a hint, repair or gap probe is for; the id the catalog entry and so binding were read by, never a value taken from a cursor or the runtime journal. It is the only place that id exists: the request the reader is then asked names the runtime id, a one-way derivation of the public one. Cursors are wrapped to the public session and its binding. A legacy-bound session is still read from WithSessionReader. A resolver error is an error, never an empty journal; return SessionStore's typed errors (or wrap them) so /journal classifies them.

Project the runtime journal to public identities with Host's NewPublicJournals. A Host's runtime journal bodies carry runtime identities (the binding's RuntimeSessionID, runtime command ids) that must never reach a client. host (>= v0.10.0) exports the projection; Factory cannot import host, so the composition (Carbon, the tests lane) applies it. Share one *host.PublicJournals across calls -- it caches each session's mapping (LRU, 0 takes the default 1024):

publicJournals := host.NewPublicJournals(0)

factory.WithSessionJournalResolver(func(ctx context.Context, tenant sessionwire.TenantID,
	session sessionwire.SessionID, binding sessionstore.SessionBinding) (factory.JournalReader, error) {
	if binding.StorageBindingID != myBindingID || binding.BindingVersion != myVersion {
		return nil, fmt.Errorf("unknown journal binding %q/%q", binding.StorageBindingID, binding.BindingVersion)
	}
	// One store per tenant, opened once and cached; closed at shutdown.
	store, err := journals.forTenant(ctx, tenant, func(ctx context.Context) (*sessionstore.Store, error) {
		return sessionstore.Open(ctx, runtimeBackend(tenant), sessionstore.WithLegacySingleTenant(tenant))
	})
	if err != nil {
		return nil, err
	}
	// Refuses (host.ErrPublicJournalScope) any request not for this tenant and
	// binding.RuntimeSessionID; bodies are byte-identical to the live tail's.
	return publicJournals.Reader(store, tenant, session, binding)
})

Without the projection (the deprecated WithJournalResolver, or a WithSessionJournalResolver that returns the store directly) /journal serves the runtime journal's bodies as written, runtime ids included.

The unprojected composition (v0.9.0 shape) needs no adapter. Harness writes its journal in SessionStore's own envelope format on its legacy single-tenant layout, so a *sessionstore.Store opened over the runtime's backend the way Harness opens it is the JournalReader:

factory.WithJournalResolver(func(ctx context.Context, tenant sessionwire.TenantID,
	binding sessionstore.SessionBinding) (factory.JournalReader, error) {
	if binding.StorageBindingID != myBindingID || binding.BindingVersion != myVersion {
		return nil, fmt.Errorf("unknown journal binding %q/%q", binding.StorageBindingID, binding.BindingVersion)
	}
	// One store per tenant, opened once and cached; closed at shutdown.
	return journals.forTenant(ctx, tenant, func(ctx context.Context) (*sessionstore.Store, error) {
		return sessionstore.Open(ctx, runtimeBackend(tenant), sessionstore.WithLegacySingleTenant(tenant))
	})
})

runtimeBackend(tenant) is the backend the Host's Harness writes that tenant's journals to (its storage.Composite, e.g. NATS or Postgres ledger plus an s3store Blobs provider). SessionStore's Open refuses a Blobs provider without the bounded reader lifecycle (fsstore), so a filesystem deployment wraps its Blobs as Carbon does. Refuse any binding you do not know: a resolver answering a default serves one deployment's journal under another's configuration. SessionStore's legacy layout marker admits one tenant per backend: Open persists the tenant in the backend's layout marker and refuses another, so runtimeBackend(tenant) must be exactly the backend the Host's Harness uses for that tenant -- not a shared one -- and the session is addressed by binding.RuntimeSessionID, which must be a canonical UUID (the rendering Harness files a journal under).

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 carries 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 are torn down. It never touches a Host runtime: a session outlives every Factory replica.

For a shared Factory and Host process, shut down in this order: Server.Quiesce(ctx) fences new command admission, waits for admissions already inside to return, and closes ClientLinks. Requests already past the router, including durable reads, may finish; new public requests receive 503. The HTTP listener, sweeps and HostLinks remain live. Observe bounded durable command settlement directly in the store, then drain Host while those links are live. Call Server.Stop(ctx) before closing the shared storage backend. Canceling a Quiesce caller's context ends only that caller's wait; the quiescence attempt continues. A Factory-only replica may call Stop directly, which invokes Quiesce itself.

New also composes command admission, the ClientLink node, the HostLink pool, the routing and live-tail planes, and the placement and disposition sweeps. Start runs the background components. Pooled placement requires WithPendingCommands; without it the replica logs a warning and places no session (an error-level log if public creates are also composed). Either option also requires WithSessionJournalResolver (above).

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.

v0.16.0 release notes

Factory now pins sessionstore v0.15.0. Journal tip reads use TipOnly, so an offloaded public event above the 512 KiB page budget no longer closes a viewer. Journal catch-up and gap repair can read offloaded public bodies up to 16 MiB through this SessionStore version. Rollout: deploy this Factory before relying on large-event catch-up.

The transport moves to centrifuge v0.39.3, centrifuge-go v0.12.1 and protocol v0.22.1, fixing the Node shutdown goroutine leak and picking up the GHSA-4r3x-2rwr-6w65 stream decoder fix. The wire version is unchanged; mixed old/new Factory and Host fleets interoperate. Shutdown treats an unbind on a gone HostLink connection as already released, because the Host drops that connection's routes on disconnect. The upstream centrifuge-go close/reconnect race remains; bounded Close and the race stress skip remain.

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.39.3
Go client, for HostLink github.com/centrifugal/centrifuge-go v0.12.1
Wire library both depend on github.com/centrifugal/protocol v0.22.1
Browser client npm centrifuge 5.7.2

The server, client and wire library move together. Factory embeds the server for ClientLink and dials with the client for HostLink in one binary, so both resolve ONE centrifugal/protocol version. Under server v0.38.0 that held the client at v0.12.0: centrifuge-go v0.12.1 pulls protocol v0.21.0, which drops the GetStreamCommandDecoder the v0.38.0 server calls (an executable compatibility test, built and run rather than inferred from a go.mod require line). Server v0.39.x builds against the limited decoder, so the pins moved as one triple. v0.39.0 also fixes the v0.38.0 Node.Shutdown leak of one metrics goroutine per Node, which internal/realtime/transport now asserts. The browser client was re-measured against v0.39.3 with the spike harness that selected it.

centrifuge-go v0.12.1 is a protocol bump only (its client.go is identical to v0.12.0's), so its close/reconnect races are not fixed in any release; HostLink's bounded Close and the -race skip on the reconnect stress case stay.

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.

Deployment and recovery

Factory's listener is public; the caller supplies it to Serve and owns TLS termination. Handler embedders must configure their own HTTP server limits. WithCSRF is required and has no default trusted origin or shared key. Factory verifies bearer, cookie or ClientLink credentials with the deployer's verifier; authorization and the origin/CSRF guard apply at the public edge.

Keep one application-scoped ClientLink per browser and subscribe to session channels on it (MaxChannelsPerConnection defaults to 256). After a lost tail or session.reset, read the durable session journal through the HTTP read plane. Centrifuge history and recovery are disabled; a reconnect may land on another replica. This composition needs no sticky routing, notifier, cache, Redis or NATS broker. NATS may separately be selected as a Storage backend.

Pooled and dedicated describe placement, not durability. Durable recovery requires a shared SessionStore backend, its leases and the Harness journal; object bytes require the selected object store. A Host session's tool-result captures live in the runtime's store under the binding's RuntimeSessionID; WithSessionObjectStoreResolver resolves that store from the principal's tenant, the public session and the catalog binding, and Factory addresses the read to the runtime id. The object route serves only with an injected ObjectPolicy (503 without one), serves only tool-result objects, answers a policy denial with the same 404 as an absent object, and verifies at most 64 MiB per object. A policy returning identity.ErrRateLimited answers retryable 429; identity.RateLimitedError also supplies Retry-After. A dedicated workload is controlled by its workload controller, including drain and termination; Factory does not issue a HostLink drain or implement drain-before-delete in its placement seam.

Factory exports no Prometheus /metrics handler or factory_ series. It publishes no direct backlog, resident wait, delivery queue, reconciliation or drain metric. Durable records and the owning Host/controller provide only partial operational visibility until Factory metrics are implemented. Stop stops public admission and background work; it does not stop a resident Host runtime.

Gate responses require a Host that advertises hostlink.command.gate_response (host v0.4.0 or later), with Factory pinned to sessionstore v0.12.0 or later before any Host publishes a gate. Keep Factory v0.5.0 or later paired with Host v0.3.0 or later and a bare advertised HostLink base. A cold AskUser answer/resume is unsupported; the gate path is for a resident session.

Dedicated placement's first-attach path additionally requires Factory ≥ v0.6.0 paired with a workload controller implementing WorkloadEndpointDiscovery (e.g. looprig/controller ≥ v0.2.0), which lets Factory discover a ready dedicated Host's pre-attach endpoint before any resident session exists. Factory v0.5.0 remains storage-compatible but cannot discover that endpoint, so it cannot place a new dedicated session against a controller of that shape.

Principal stamping and message metadata (v0.12.0)

Factory accepts client metadata on create and input. Core limits it to 16 string-to-string fields, keys of at most 64 bytes, values of at most 1024 bytes, and 4096 bytes total; keys with the looprig prefix are reserved. A client-supplied principal on any command kind is refused on REST and ClientLink with 400 invalid_request (identity.ErrClientPrincipal), whether or not stamping is enabled.

WithPrincipalStamping() opts a deployment into stamping the verified credential's tenant, subject and actor/service kind on every admitted create, input, interrupt, restore and gate response. It is off by default; WithCredentialVerifier is already a required Factory seam. The stamped principal and metadata are part of the canonical command identity: a retry of the same command_id by a different subject is 409 command_rejected and does not reattribute the stored command. Enabling stamping also makes an in-flight retry of a previously unstamped command command_rejected; enable it during a quiet period.

A resident Host must advertise Core's exact hostlink.attribution.principal token before Factory admits a command carrying either member. An incapable owner is 422 runtime_unavailable (identity.ErrMetadataUnsupported) with nothing written; an owner that cannot be asked is 503 retryable. Placement skips incapable pooled or dedicated candidates and waits with NoCapacity until a capable one exists. The wake filter avoids sending an attributed hint to an incapable resident, but only admission prevents that resident from reading a newly written durable command. With stamping enabled, Start probes the registered pooled Hosts and WARNs for each one lacking the token or unable to answer. The probe is bounded to 30 seconds and 64 pages per target. It pages toward completion rather than sampling only one page; reaching either bound WARNs that some Hosts were not checked. Admission and placement enforce capability independently of this informational probe.

GET /v1/capabilities retains the /v1/agents aggregate and adds message_metadata: true and command_principal, which reports whether this deployment enabled stamping. /v1/agents keeps its old body. New GET /v1/sessions/{sid}/commands/{cid} returns public command status. Its principal and metadata members appear only when the supplied Authorizer also implements optional AuditAuthorizer and grants that read; absence, denial or an audit-authorizer fault omits both. TenantAuthorizer grants audit reads within the principal's tenant.

Roll out every Host at v0.11.0 or later first, then Factory v0.12.0; confirm the HostLink token across the fleet before enabling stamping. One-way: once any attributed command is stored, every Factory and Host sharing the store must remain on sessionstore v0.14.0 or later. Older readers refuse the new durable record version. Once any stamped or presented record exists, rolling Harness below v0.41.0 is also forbidden: old Harness decoders silently drop the additive members, so rollback is lossy. In particular, an applied input not yet started may be re-offered without its principal or presenter frame.

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. Host may not be imported here, so this module's tests run against a stand-in node that gates the upgrade as a Host does. They pin Factory's side; the cross-module proof against a released Host lives in the tests integration lane.

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 placement's (see Placement).

Live output (Gap 3, v0.4.0)

A watched session's live output reaches the viewers watching it. When this replica places a session already watched locally, it rebinds the tail immediately after attach. A different replica's placement is picked up by the five-second ownership poll, independent of the 30-second subscriber release debounce. 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. At mailbox capacity, a valid ephemeral arrival is dropped; non-ephemeral publications (enduring, or any record the Relay will refuse) evict queued ephemerals. Control events are not bounded. Ephemeral drops do not advance journal coverage or restart the tail. Since v0.8.1 a Host record whose own tenant or session is not the tail's it arrived on is refused (routing.ErrForeignRecord, a delivery.ErrMalformed), logged at ERROR, counted (Relay.ForeignRecords) and repaired like any refused record: it never reaches a viewer, and the viewers are reset. factory.New composes the pool, and Start drives the reaper on the sweep cadence.

A record the live tail never carried is not skipped silently (v0.9.0, I3.1 D2). A Host relays public records only, and it can commit one it never relays -- a warm release commits SessionResidencyReleased after its tail stopped -- before a re-placed session's new tail arrives on the same subscription. Once the tail's position is known (anchored at a first viewer's tail start, or at the tip a reset sent every viewer to), a record that skips positions is checked against the journal with one bounded read: if a PUBLIC record lies in the gap, every viewer is sent a session.reset to the journal's tip in front of it; a gap of private records needs none. A tip that cannot describe the gap closes the viewers instead. This needs a journal resolver (WithSessionJournalResolver) for Host sessions -- the check reads the runtime journal.

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; Factory ships no binary and grants itself no workload create/delete RBAC, so a nil controller is a supported configuration and a dedicated session reaching a replica composed without one 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.

A Host restarted under its HostID is followed promptly (v0.9.0, I3.1 D1). A pooled Host that restarts the way a pod does keeps its HostID and comes back at a higher generation on a new address. The HostLink pool now replaces a cached (Host, tenant) link whose Host advertises a new address under a HIGHER generation -- on the next bind, attach or gate-capability read -- closing the old link and dropping its routes (a viewer's route is re-bound by the routing plane's repair). Before, the replica kept redialling the dead address until the 60 s idle reaper collected the link, or never while a viewer pinned it. An older or same-generation observation of another address does not move a link.

Pooled placement attaches (B5). With WithPendingCommands, each admitted command prompts a bounded placement attempt for its session. The "placement" sweep still pages one control shard of the disposition inbox per interval and recovers notifications lost to a full queue, crash, restart or another replica. The paths use distinct durable claim holders, so a concurrent sweep defers to an admission attempt. Admission workers read the session's disposition inbox before placing it. If settled history fills that read, they use the bounded due view to find open work and its Host capability needs. A busy due shard may defer the attempt to the periodic sweep. Older pending-query adapters without the session read use a bounded shard scan and fall back to the sweep if it is incomplete. Each placement pass has its own deadline (ReconcileLimits.PassTimeout, default 10 seconds, capped at half ClaimTTL), independent of the sweep interval. For each session with open work and no live owner, placement 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.15.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 gates read (v0.9.0). GET /v1/sessions/{sid}/gates reports a gate stored resident as unavailable whenever an answer would be refused gate_not_resumable -- no fresh matching owner (its Host released the session crash-equivalently, is releasing it, or crashed and lapsed), or an owner without the token -- and also when the owner could not be asked (the write's 503). It is the admission service's own check (GateResponsesAnswerable, the one AdmitGateResponse makes), so the read and the write agree. The gate itself is still listed; a successor restores and re-publishes it. Other stored values are left as they are.
  • 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.

Inbound body limits (Factory v0.14.0). WithClientLinkLimits accepts ClientLinkLimits.MaxMessageBytes from 64 KiB (the default) through 16 MiB. WithRouteLimits accepts RouteLimits.MaxCommandBytes from 1 MiB (the default) through 16 MiB for REST create and input; other REST JSON routes retain MaxRequestBytes (1 MiB by default). The WebSocket transport admits frames through exactly 65,536 bytes at the default; an oversized frame closes the transport, with no typed response promised. When MaxMessageBytes is raised, the frame cap is that limit plus 16 KiB of RPC envelope headroom. A payload over the configured limit that fits in the frame receives a typed invalid_request RPC response; a frame over the cap closes the transport. A REST command over its configured limit receives HTTP 413 with invalid_request. Gate responses keep their 64 KiB canonical payload bound regardless of either setting.

A pending create or input whose canonical payload is stored by reference requires a Host advertising Core's HostLinkCapabilityPayloadReference (hostlink.payload.reference) in hostlink_methods. Factory skips incapable Hosts during pooled or dedicated placement and withholds a referenced command wake from an incapable owner. The accepted command remains pending until a capable Host is available; Factory does not turn absence of that capability into an admission refusal.

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.

Development

The Go baseline is 1.26.9. Verify standalone, with no workspace masking:

GOWORK=off go test ./...
make check   # fmt-check, vet, staticcheck, gosec, govulncheck, race tests, HostLink stress, bounded fuzz, build

Individual targets: make test (race, uncached), make stress, make fuzz, make fmt, make fmt-check, make vet, make staticcheck, make gosec, make vuln, make secure, make build. The 5,000-connection transport case runs only under the transportscale build tag.

License

Apache License 2.0; see LICENSE.

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.39.3/client.go:3384 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
	// object resolver behind it -- neither WithSessionObjectStoreResolver nor
	// the deprecated WithObjectStoreResolver. See WithObjectPolicy.
	ErrObjectPolicyWithoutResolver = errors.New("factory: WithObjectPolicy requires WithSessionObjectStoreResolver")
	// ErrSessionBindingWithoutResolver reports a SessionBinding composed with
	// no object resolver behind it. See WithSessionBinding.
	ErrSessionBindingWithoutResolver = errors.New("factory: WithSessionBinding requires WithSessionObjectStoreResolver")
	// 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 ErrConflictingJournalResolvers = errors.New("factory: WithSessionJournalResolver and WithJournalResolver are alternatives; supply one")

ErrConflictingJournalResolvers reports a composition that supplies both WithSessionJournalResolver and the deprecated WithJournalResolver. They are alternatives for the same read plane, and neither silently shadows the other: preferring the deprecated one would read a Host's journal without the public session id its projection needs, and preferring the other would ignore a value the deployer supplied. It is reported as an *OptionError naming WithSessionJournalResolver.

View Source
var ErrConflictingObjectResolvers = errors.New("factory: WithSessionObjectStoreResolver and WithObjectStoreResolver are alternatives; supply one")

ErrConflictingObjectResolvers reports a composition that supplies both WithSessionObjectStoreResolver and the deprecated WithObjectStoreResolver. They are alternatives for the same route and neither silently shadows the other. It is reported as an *OptionError naming WithSessionObjectStoreResolver.

View Source
var ErrHostSessionsWithoutJournalResolver = errors.New("factory: WithPublicCreates and WithPendingCommands require WithSessionJournalResolver")

ErrHostSessionsWithoutJournalResolver reports a composition that creates or places Host-owned sessions (WithPublicCreates or WithPendingCommands) with no journal resolver -- neither WithSessionJournalResolver nor the deprecated WithJournalResolver -- to read their journals. It is reported as an *OptionError naming WithSessionJournalResolver. See WithSessionJournalResolver.

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 AuditAuthorizer added in v0.12.0

type AuditAuthorizer interface {
	AuthorizeAuditRead(context.Context, identity.Principal, sessionwire.SessionID) error
}

AuditAuthorizer optionally grants a command's principal and metadata on GET /v1/sessions/{sid}/commands/{cid}. Without it, public status remains readable but the audit members are omitted.

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 {
	// MaxMessageBytes bounds the data in one inbound command RPC. At the 64 KiB
	// default, the WebSocket frame cap is exactly 64 KiB. When raised, the frame
	// cap is MaxMessageBytes plus 16 KiB for the RPC envelope. A payload over
	// MaxMessageBytes within that cap receives a typed invalid_request refusal;
	// a frame over the cap closes the transport.
	MaxMessageBytes int
	// 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.39.3/node.go:146-147 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.39.3'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

	// OwnershipPollInterval is the gap between registry polls for unbound watched sessions.
	// Zero retains the five-second default for older keyed literals.
	OwnershipPollInterval 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 JournalReader added in v0.9.0

type JournalReader interface {
	ReadPublicJournal(ctx context.Context, req sessionstore.ReadPublicJournalRequest) (sessionwire.JournalPage, error)
}

JournalReader is the read of one session's RUNTIME journal: the journal a Host-owned session's runtime writes. A *sessionstore.Store opened over the runtime's own backend satisfies it -- for a Harness runtime that is sessionstore.Open(ctx, harnessBackend, sessionstore.WithLegacySingleTenant(tenant)), the layout Harness writes its journal in.

It is addressed by the binding's RuntimeSessionID, never by the public session id: Factory rewrites the request before it is called.

type JournalResolver deprecated added in v0.9.0

type JournalResolver func(ctx context.Context, tenant sessionwire.TenantID, binding sessionstore.SessionBinding) (JournalReader, error)

JournalResolver answers the runtime journal a Host-owned session's binding names, for that session's tenant, and refuses any binding this deployment does not know.

The refusal is the point, for ObjectStoreResolver's reason: a resolver that answered a default for an unknown StorageBindingID or BindingVersion would serve one deployment's journal under another's configuration. Return the store's own typed errors (or wrap them with %w): the /journal route classifies a *sessionstore.JournalError and the catalog's absence errors, and anything else is answered 500.

Deprecated: use SessionJournalResolver, which is also handed the public session id -- the only id a Host's public-journal projection can scope a session's bodies to.

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, and since v0.11.0 it is answered 404 with the same body as an absent object (v0.10.0 answered 403), so a caller cannot tell a reference it may not read from one that does not exist. Any other error is a policy dependency fault (500). The kind returned must be sessionstore.ObjectKindToolResult; the route serves no other.

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 object resolver is REFUSED -- see WithObjectPolicy.

type ObjectReader

type ObjectReader = httpapi.ObjectReader

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

type ObjectStoreResolver deprecated

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.

The reader it returns is asked by the principal's tenant and the PUBLIC session id.

Deprecated: use SessionObjectStoreResolver. This resolver is handed neither the tenant nor the public session, and its reader is addressed by the public session id -- but a Host-owned session's objects (a Harness runtime's tool-result captures) live under the binding's RuntimeSessionID in the runtime's own store, so through this resolver they are never found.

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 WithJournalResolver deprecated added in v0.9.0

func WithJournalResolver(r JournalResolver) Option

WithJournalResolver supplies where a Host-owned session's journal is read.

A Host-owned session is DISPOSITION-bound, and its journal is not in the SessionStore WithSessionReader reads: the Host's runtime keeps it on its own backend, under the binding's RuntimeSessionID, and SessionStore refuses to write a public journal for a disposition session at all. Read through WithSessionReader alone, every Host session's journal is empty at tip 0 -- so /journal shows no history, the journal_tip hint says 0, and every live-tail repair after a delivered record cannot build a session.reset (Core refuses last_contiguous above journal_tip) and UNSUBSCRIBES the session's viewers instead of resetting them.

With it, every journal read Factory makes -- the /journal route (and so its journal_tip), the journal_tip hint an unbound watched session publishes, and the tip every live-tail repair resets to -- reads the catalog binding for the PUBLIC session id and, for a disposition-bound session, reads the resolved runtime journal under the binding's RuntimeSessionID. Cursors are wrapped so a page's continuation is bound to the public session and its binding. A session that is not disposition-bound (legacy) is read from WithSessionReader, exactly as before.

It is REQUIRED with WithPublicCreates or WithPendingCommands: a replica that creates or places Host sessions and cannot read their journals is refused by New with ErrHostSessionsWithoutJournalResolver.

Deprecated: use WithSessionJournalResolver. This resolver is not handed the public session id, so it cannot return a reader that projects a Host's runtime journal to public identities, and /journal then serves the runtime session and command ids a Host's bodies carry. It keeps its v0.9.0 behaviour, and cannot be combined with WithSessionJournalResolver.

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 an object resolver (WithSessionObjectStoreResolver, or the deprecated 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 deprecated

func WithObjectStoreResolver(r ObjectStoreResolver) Option

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

Deprecated: use WithSessionObjectStoreResolver. This resolver's reader is addressed by the public session id, so a Host-owned session's objects, stored under its RuntimeSessionID, are never found through it. It keeps its v0.10.0 behaviour and cannot be combined with WithSessionObjectStoreResolver.

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 WithPrincipalStamping added in v0.12.0

func WithPrincipalStamping() Option

WithPrincipalStamping stamps the verified sender on every admitted command. It is off by default and requires the already-mandatory credential verifier. Upgrade Hosts to ones advertising hostlink.attribution.principal before enabling it. A retry admitted before enablement changes identity, and attributed rows require every reader to stay on sessionstore >= v0.14.0.

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 WithRouteLimits added in v0.14.0

func WithRouteLimits(l RouteLimits) Option

WithRouteLimits replaces the REST request and command body limits.

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 an object resolver (WithSessionObjectStoreResolver, or the deprecated 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 WithSessionJournalResolver added in v0.10.0

func WithSessionJournalResolver(r SessionJournalResolver) Option

WithSessionJournalResolver supplies where a Host-owned session's journal is read, exactly as WithJournalResolver does, and hands the resolver the public session id each read is for, so a composition can return a reader that projects the runtime journal to public identities:

journals := host.NewPublicJournals(0)
factory.WithSessionJournalResolver(func(ctx context.Context, tenant sessionwire.TenantID,
	session sessionwire.SessionID, binding sessionstore.SessionBinding) (factory.JournalReader, error) {
	store, err := runtimeStore(ctx, tenant, binding) // refuses an unknown binding
	if err != nil {
		return nil, err
	}
	return journals.Reader(store, tenant, session, binding)
})

It is the replacement for WithJournalResolver, and satisfies the same requirement (WithPublicCreates and WithPendingCommands need one of them). Supplying both is refused by New with ErrConflictingJournalResolvers.

func WithSessionObjectStoreResolver added in v0.11.0

func WithSessionObjectStoreResolver(r SessionObjectStoreResolver) Option

WithSessionObjectStoreResolver supplies where a bound session's objects are read: GET|HEAD /v1/sessions/{sid}/objects/{oid} and its /metadata.

factory.WithSessionObjectStoreResolver(func(ctx context.Context, tenant sessionwire.TenantID,
	session sessionwire.SessionID, binding sessionstore.SessionBinding) (factory.ObjectReader, error) {
	return runtimeStore(ctx, tenant, binding) // refuses an unknown binding
})

What the route guarantees around it:

  • It serves only with an ObjectPolicy (WithObjectPolicy). With none, every object read answers 503 "unavailable" before the catalog summary, the policy or this resolver -- the same answer v0.10.0 gave, kept so a composition that serves no objects is not moved.
  • The policy is asked FIRST, with the catalog entry; this resolver is reached only for a reference it authorized. A denial (identity.ErrUnauthorized) answers the same 404 body as an object the store does not hold, so a caller cannot tell "not yours" from "not there". Evidence belongs to the policy, not to the store: SessionStore's metadata index is not authorization.
  • Only ObjectKindToolResult is served. A policy answering any other kind is a policy fault (500), refused before this resolver is asked.
  • Size is bounded by ObjectLimits: at most 1 MiB per page and 64 MiB of whole-object verification. A larger object answers 413 however it is paged, so a runtime's capture ceiling must stay within MaxVerificationBytes (I2.2 D7) -- that is the composition's to assert; Factory cannot see the runtime's ceiling.
  • Tenant and session come from the principal and the catalog, never from the client; the object reference is resolved only within the addressed (tenant, runtime session) scope, so another session's reference is absent.

It is the replacement for WithObjectStoreResolver and satisfies the same requirements (WithObjectPolicy and WithSessionBinding need one of them). Supplying both is refused by New with ErrConflictingObjectResolvers.

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.

A Factory process composes none by default: H5 puts the platform adapter in the separate looprig/controller repository, 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 enables immediate admission-triggered placement and the durable recovery sweep. Each sweep interval pages one control shard for commands still open; 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 admission workers. A placement sweep can run alongside them.
	MaxConcurrent int
	// PassTimeout bounds one sweep or admission-triggered placement pass.
	// Zero uses the default for older keyed limits literals.
	PassTimeout time.Duration
}

ReconcileLimits bounds Factory's periodic reconciliation and immediate placement of admitted commands whose sessions have 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 RouteLimits added in v0.14.0

type RouteLimits = httpapi.RouteLimits

RouteLimits bounds REST JSON bodies and request work.

func DefaultRouteLimits added in v0.14.0

func DefaultRouteLimits() RouteLimits

DefaultRouteLimits returns the REST defaults.

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) RouteLimits added in v0.14.0

func (s *Server) RouteLimits() RouteLimits

RouteLimits returns the configured REST request and command 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: Factory ships no UI and no binary, so a product mounts its own bundle or handler through WithUIHandler/WithUIFS, and an embedder that mounts 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 SessionJournalResolver added in v0.10.0

type SessionJournalResolver func(ctx context.Context, tenant sessionwire.TenantID, session sessionwire.SessionID, binding sessionstore.SessionBinding) (JournalReader, error)

SessionJournalResolver is JournalResolver handed, as well, the PUBLIC session id the read is for.

A Host's runtime journal carries runtime identities (the binding's RuntimeSessionID, runtime command ids) that must never reach a client, and a Host projects them to public ones (host.NewPublicJournals). That projection needs the public session id, and nothing else a resolver is handed carries it: the binding's RuntimeSessionID is a one-way derivation of the public id, and the request the returned JournalReader is asked is already addressed by the RuntimeSessionID.

session is the id Factory routed the read for: the authorized /journal route's session, or the watched session a tip hint, live-tail repair or gap probe is for. It is the id the catalog entry -- and so binding -- was read by, never a value taken from the runtime journal or a cursor. The same refusal rules as JournalResolver apply: refuse any binding this deployment does not know, and return SessionStore's typed errors (or wrap them).

type SessionObjectStoreResolver added in v0.11.0

type SessionObjectStoreResolver func(ctx context.Context, tenant sessionwire.TenantID, session sessionwire.SessionID, binding sessionstore.SessionBinding) (ObjectReader, error)

SessionObjectStoreResolver answers the store a bound session's objects live in, and is the object route's counterpart of SessionJournalResolver.

It is handed the principal's tenant, the PUBLIC session id the route was authorized for (the id the catalog entry -- and so binding -- was read by, never a value taken from the request body or the store), and the catalog's full immutable binding. It must refuse any binding this deployment does not know, for ObjectStoreResolver's reason.

The reader it returns is asked with the principal's tenant and the binding's RUNTIME session id: Factory rewrites every GetObjectMetadata and GetObject request before it is called, exactly as it rewrites a journal read. For a Harness runtime that reader is the per-tenant runtime store -- sessionstore.Open(ctx, harnessBackend, sessionstore.WithLegacySingleTenant(tenant)), the same store WithSessionJournalResolver answers -- where Harness writes a tool-result capture under the runtime session. The runtime session id never reaches a client: object metadata names no session.

It is consulted only for a session with a non-zero binding; an unbound (legacy) session's objects are read from WithSessionReader under the public id, unchanged. And it is consulted only AFTER the ObjectPolicy has authorized the reference -- see WithSessionObjectStoreResolver.

type SessionReader

SessionReader is the durable read plane.

Its ReadPublicJournal is keyed by the PUBLIC session id, so it answers the journal of a legacy-bound session only. A Host-owned (disposition) session's journal lives on the runtime's backend under its binding's RuntimeSessionID and is read through WithSessionJournalResolver; without one it reads empty at tip 0.

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) AuthorizeAuditRead added in v0.12.0

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

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 a Factory process 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