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

harnessadapter

package
v0.5.0 Latest Latest
Warning

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

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

Documentation

Overview

Package harnessadapter binds Host's department seams to the released github.com/looprig/harness rig and session API (v0.35.0 as pinned).

Step-1 inventory

LOCAL SEAM                              RELEASED COUNTERPART
department.Rig.NewSession               rig.Rig.NewSession          (H1, H2)
department.Rig.RestoreSession           rig.Rig.RestoreSession      (H2)
department.RigSession.ID                session.Session.SessionID
department.IdleWaiter.WaitIdle          session.IdleWaiter          (H0)
department.Liveness.Done                session.Liveness            (H0)
department.Releaser.ReleaseResidency    session.Releaser            (H0)
department.PublicationSubscriber.Sub…   session.CommittedPublicEvent…
                                          …Provider/Source          (H0, H3, H4)
department.CommandApplier.ApplyCommand  runtimecommand.Provider/Applier
department.LeaseEpochReporter.Lease…    session.LeaseEpochReporter  (H5)
                                          (H0, H5, H6, H7, H8)

SEAMS NOT IN THE TABLE ABOVE, stated because the sessionstore side states its exclusions and an inventory without one cannot be read as complete. department.Runtime, department.Department, department.Target and department.CompatibilityID are Host types describing what Host requires of a runtime; they stand in for no released type and binding them here would invent a dependency rather than record one. department.RigCreateRequest and department.RigRestoreRequest are the arguments to the two rows that ARE bound, not seams of their own. The Harness identities Host cannot adopt — core/uuid.UUID against the opaque sessionwire strings — are the subject of CLAUDE.md's paragraph on why an adapter is mandatory, not a row that could be bound differently.

NONE OF THE FIVE IS ON SessionController, AND ALL FIVE ARE PUBLISHED. That distinction is the whole of H0 and an earlier version of this file got it wrong. session.SessionController — the type rig.NewSession and rig.RestoreSession return — declares neither WaitIdle, nor Done, nor ReleaseResidency, nor a committed-public subscription, nor a command applier. But harness SEGREGATES them rather than omitting them: session.IdleWaiter, session.Liveness, session.Releaser, session.CommittedPublicEventSource with its session.CommittedPublicEventProvider, and runtimecommand.Applier with its runtimecommand.Provider are all exported, and each documents the assertion as the way a caller is meant to discover it.

SO THIS PACKAGE ASSERTS ON HARNESS'S OWN NAMES, never on a private structural twin. A private interface cannot produce a compile failure against a foreign type — a released signature change would arrive as a silent ok == false and an IncapableSessionError at run time, which is the opposite of the guard such a declaration appears to offer.

WHAT REMAINS UNENFORCED IS NARROWER, AND IT IS HARNESS'S OWN STATED HAZARD: the assertion is on the DYNAMIC type, so a wrapper that fails to forward a method silently opts its session out. session.IdleWaiter's doc says exactly that, and adds that nothing pins rig to keep returning the runtime type unwrapped.

Findings — the both-directions audit of step 2

H1. NEWSESSION TAKES NO IDENTITIES, NO WORKSPACE ROOT AND NO STORAGE CONTEXT. department.RigCreateRequest carries TenantID, SessionID, AgentID, Placement, WorkspaceRoot and Storage.Namespace. rig.Rig.NewSession takes only SessionOptions, of which the released set is one: WithSeedSnapshot. The workspace root and the resource storage are fixed when the Rig is DEFINED, not when a session is created. So a Host that materializes a per-session workspace cannot hand it to a shared rig, and this adapter takes a per-request rig instead — see Rigs. The fake accepts all six fields and asserts on them.

H2. RESTORESESSION TAKES ONLY A UUID. department.RigRestoreRequest carries the same six fields; rig.Rig.RestoreSession(ctx, id) accepts none of them. Every one is dropped. This adapter does not hide the drop and it cannot repair it: the request is forwarded to Rigs.RigForRestore, which is the one place a composition can honour a workspace root or a storage namespace, and past that seam only the UUID crosses. An adapter that took the request and forwarded nothing would look like it propagated six fields.

H3. THE LIVE SUBSCRIPTION CANNOT BE POSITIONED. Host's PublicationSubscriber delivers "committed public journal events AFTER the given event", which is how a client joins the live path with a bounded journal read. event.EventFilter selects loops and classes; it has no start position and there is no other parameter. A non-empty resume point is therefore refused rather than silently served from now — a silently-repositioned subscription is a gap in a client's journal that neither side can see.

H4. THE PUBLICATION IS ASSEMBLED, NOT PROJECTED. event.Delivery carries EventID, JournalSeq, PublicBody and CoveredThrough — everything sessionwire.EnduringPublication needs except the tenant and the session, which harness does not know. This adapter supplies them from the launch request. It also drops deliveries that are not committed publications, which the fake's closed channel never produces.

H5. ADMITTED REQUIRES A LEASE EPOCH AND THE COMMAND SEAM CARRIES NONE. runtimecommand.Admitted.Validate refuses a zero LeaseEpoch outright. department.RuntimeCommand has no epoch member at all, and commands.Applier constructs it from a record without one.

H5 AS RESOLVED BY O3.4, AND THE FIRST ANSWER WAS A DEFECT. The epoch used to be supplied to this adapter at bind time, through WithLeaseEpoch, which made it "a property of the BINDING rather than of the command". That was the weaker half of the problem; the stronger half went unsaid. THE COMPOSITION DOES NOT HAVE THIS NUMBER. harness checks an admitted command's LeaseEpoch for EQUALITY against the single-writer lease the session itself holds (internal/sessionruntime/runtime_command.go:140), and the only lease Host holds is its residency grant — a different issuer over a different namespace, which sessionstore.ResidencyEpoch says in terms must never be used as a journal epoch. The bound number was therefore right exactly while two independent per-session counters happened to agree, which under memstore they do because both start at 1. It would have failed deterministically the first time the two were acquired out of step.

harness v0.33.0 publishes session.LeaseEpochReporter, and the epoch is now read from THE RUNTIME, per command, through that capability. WithLeaseEpoch is gone. A session that reports no held grant — headless, no persistence, not wired for durable commands, or a lease already released — is refused with an UnsupportedCommandError rather than stamped with a plausible zero, because the capability's two results exist to separate "no epoch" from "epoch 0" and harness gates the report on the lease still being Valid.

H6. HARNESS APPLIES ALL FIVE KINDS SINCE v0.36.0, AND H6 IS LIFTED. runtimecommand.Kind named input and interrupt, then gate_response at v0.35.0, then create and restore at v0.36.0; commands.Kind has the same five. Until then create and restore were refused here — and that refusal was the wedge v0.36.0 exists to end: Host had already begun a durable dispatch attempt by the time the adapter said no, no disposition frame was ever written, the store could never settle the record, and the consumer blocked at the session's FIRST command forever. The gate is Kind.Valid() and always was, so nothing in this package had to change for the kinds to start crossing.

WHAT DID HAVE TO CHANGE IS THE PAYLOAD, AND MISSING IT IS SILENT DATA LOSS. The decode below used to be reached only by KindInput. A create crossing with no Blocks is a command harness accepts — Core's CreateRequest.Blocks is optional, so an empty one is a legitimate idle create — which the applier then settles `applied` having sent nothing. The user's first message would be dropped with the record looking perfectly settled, which is worse than the wedge it replaced because the wedge was visible. harness cannot detect it: an undecoded payload and an absent one are the same value on its side of the seam. So a create's body is read here, as Core's CreateRequest, and its blocks are re-presented in the input-shaped body a BlockDecoder reads; see internal/createbody for why there is one encoding rather than two. A create carrying a first message with no decoder bound is refused, exactly as an input is by H7.

A RESTORE CROSSES CARRYING NOTHING. Core's RestoreRequest has no blocks member and Admitted.Validate refuses a restore that carries any. harness gates its input preparation on the PAYLOAD rather than the kind, so a restore never borrows the input path's loop refusals and a resident session's resume is not refused because some loop had exited.

A gate_response is admitted with harness's decoded answer (Admitted.GateResponse), built from the stored Core body by internal/gateresponse — the same decode the applier ran before the attempt — with the user as its source and the attempt the record authorized, which harness requires for this kind.

ONE-WAY UPGRADE, and it is Host's to state as well as harness's. Once a session journal holds a create or restore application prefix or disposition frame, harness v0.35.0 and older can neither replay nor reopen it. Do not roll a Host back below harness v0.36.0 once it has applied either kind.

H7. AN input COMMAND MUST CARRY DECODED content.Blocks, AND HOST'S PAYLOAD IS OPAQUE BYTES. department.RuntimeCommand documents the body as travelling opaque because "Host is not the semantic validator of a command body — Harness is". Admitted.Blocks is []content.Block and Validate refuses an input with none, so somebody must decode. This adapter takes a decoder rather than guessing an encoding, and refuses when it has none.

H8. AN OBJECT-REFERENCED PAYLOAD CANNOT CROSS AT ALL. RuntimeCommand.PayloadRef exists so a large private body is dereferenced by the runtime instead of travelling through Host's memory. Admitted has no reference member and no object reader, so a referenced payload is refused. The fake accepts one and records it.

H9. HARNESS WRITES THE APPLICATION PREFIX ITSELF, AND BY DEFAULT WRITES IT UNDER ITS OWN SESSION SCOPE. harness/pkg/sessionstore opens the released store with WithLegacySingleTenant and derives the session id from the Harness UUID, so every record a session commits — the opening fence, the public events, and the EnvelopeKindApplicationPrefix that Applier.ApplyRuntimeCommand writes before each effect — lands under (<tenant>, <uuid>) rather than under Host's (TenantID, SessionID). ON THE DEFAULTS the two layouts are mutually exclusive at the backend: whichever store initializes one first, the other refuses it with KeyspaceError{layout_mismatch}. Measured in both directions by TestHostCannotOpenABackendHarnessInitialized and TestHarnessCannotOpenABackendHostInitialized, both of which still pass because the defaults did not move.

H9 AT harness v0.32.0 — THE TENANT HALF IS DISCHARGED, THE IDENTITY HALF IS NOT. The paragraph that used to close this finding said Host and its runtime "cannot share one storage backend AT ALL", that "nothing in this package can fix" it, and that it was "a release owed". The first two are now FALSE and the third is narrower than it was. v0.32.0's only production change is pkg/sessionstore, and it adds WithTenant, which replaces the hardcoded "local" so a Store files under the tenant it is opened with. The rebind measured the consequence rather than trusting the doc: see TestHostAndHarnessCanShareABackendOnTheLegacyLayout, which opens ONE backend from both sides, in both initialization orders, and reads harness's session scope through Host's store.

IT IS A PARTIAL DISCHARGE AND THE TWO COSTS ARE THE FINDING. First, sharing requires Host to open with WithLegacySingleTenant; Host's native multi-tenant open over a harness-initialized backend still fails layout_mismatch, so a Host that shares a backend with its runtime is a single-tenant Host for it. Second — and this is what is still owed — the legacy layout addresses a session by the UUID's canonical rendering and refuses anything else with KeyspaceError{legacy_session}, while Host's SessionID is the opaque sessionwire identity Factory admitted the session under, which CLAUDE.md states is NOT a UUID by contract. Sharing therefore works exactly when Host's session identity happens to be the rig's UUID, which Host does not control. Both costs are asserted, not described. What remains owed is no longer "a way for two stores to share a keyspace" — that exists — but Harness ceasing to impose its own identity grammar on the shared one.

COST 2 IS RELATED TO, AND NOT THE SAME AS, sessionstoreadapter's F5. That row is Host's uuid.UUID seam being too narrow for an opaque value the store will hand it; this is Harness's keyspace being too narrow for the opaque identity Factory admitted. Opposite directions, different subjects, separately fixable — but one decision about whether a Host session identity is a UUID probably settles both, so do not fix one and assume the other went with it.

THE CONSEQUENCE IS NOT "the correlation reports absent". It cannot: commands.Applier appends its own prefix under Host's scope BEFORE driving the runtime, so Host's scope always holds a prefix and the walk always finds one. The two states it does reach are these, and they are different failures.

  • IN THE LIVE PATH THE OUTCOME IS "unresolved": the walk finds the prefix, and behind it neither the effect — which harness committed in its own scope — nor a superseding fence. Unresolved refuses BOTH settlements, so the command is stuck in applying, completable by nobody and rejectable by nobody. Under the released modules the apply path does not terminate at all. Measured by sessionstoreadapter's TestApplierRunsTheWholeProtocolAgainstTheReleasedStore, which drives two passes and finds the record still applying after both.
  • AFTER A TAKEOVER THE OUTCOME IS "abandoned": a successor Host's OpenJournal commits an opening fence above the prefix's epoch, which is precisely the shape the walk reports as abandoned. commands.Application.provesNoEffect admits abandoned, so the settlement that licenses a REJECTION opens over a command whose effect harness durably committed. That is a rejection written over a committed effect, and it is reached by crash-and-takeover rather than in steady state.

THE TWO OUTCOMES ABOVE REMAIN REACHABLE, as a configuration rather than as a wall: they are what happens when the two sides do NOT share a scope, which is still the default and is still the only option when Host's session identity is not the rig's UUID. Closing them needs the identity half of H9 above. This package cannot fix that alone; what changed is that it is no longer the whole of the finding.

Index

Constants

This section is empty.

Variables

View Source
var ErrNilOption = errors.New("harnessadapter: a nil option was supplied")

ErrNilOption is the refusal New returns for a nil option.

View Source
var ErrNoRig = errors.New("harnessadapter: the rig resolver reported success and returned no rig")

ErrNoRig is the refusal returned when a resolver reports success and hands back nothing. It is department.ErrNoRigSession one layer up, and it exists for the same reason: a nil rig would otherwise be dereferenced by NewSession.

View Source
var ErrNoRigs = errors.New("harnessadapter: no rig resolver to adapt")

ErrNoRigs is the refusal New returns when it is handed no resolver.

View Source
var ErrNoSession = errors.New("harnessadapter: the rig reported success and returned no session")

ErrNoSession is the refusal returned when a rig reports success and hands back a nil controller.

View Source
var ErrResumeUnsupported = errors.New(
	"harnessadapter: the released subscription cannot be positioned after an event")

ErrResumeUnsupported is the refusal SubscribeCommitted returns for a non-empty resume point.

FINDING H3. event.EventFilter selects loops and event classes and carries no position; nothing else in the released subscription API does either. Serving a positioned subscription from the live tail instead would open a gap between the caller's last durable event and the first live one, and neither side could see it — the caller believes it resumed, and the stream believes it started.

Functions

This section is empty.

Types

type Adapter

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

Adapter is a department.Rig over the released rig API.

func New

func New(rigs Rigs, options ...Option) (*Adapter, error)

New adapts a rig resolver into the department.Rig Host launches through.

func (*Adapter) NewSession

NewSession launches a session for a create request.

func (*Adapter) RestoreSession

func (a *Adapter) RestoreSession(
	ctx context.Context,
	id uuid.UUID,
	request department.RigRestoreRequest,
) (department.RigSession, error)

RestoreSession relaunches a session over existing durable state.

FINDING H2: every member of the request is dropped, because rig.Rig.RestoreSession takes the UUID and nothing else. The request is still taken — it is the only place an assembly can see what is being restored — and is forwarded to RigForRestore, so the drop happens at a seam a composition can act on rather than silently inside this call.

type BlockDecoder

type BlockDecoder func([]byte) ([]content.Block, error)

BlockDecoder turns a command's private body into the content blocks Harness requires.

IT IS A PARAMETER FOR FINDING H7'S REASON. Host carries the body opaque on purpose — department.RuntimeCommand says so in as many words — and runtimecommand.Admitted.Validate refuses an input command carrying no content.Block. Those two cannot both hold, so an encoding has to be named somewhere; naming it here rather than inside the adapter keeps it a composition decision, and leaving it unset makes an input command refuse rather than apply an empty turn.

type IncapableSessionError

type IncapableSessionError struct {
	Missing []string
}

IncapableSessionError reports a launched harness session missing capabilities Host requires.

func (*IncapableSessionError) Error

func (e *IncapableSessionError) Error() string

type Launcher

type Launcher interface {
	NewSession(context.Context, ...rig.SessionOption) (session.SessionController, error)
	RestoreSession(context.Context, uuid.UUID) (session.SessionController, error)
}

Launcher is the pair of released rig methods this adapter calls.

It is an interface over *rig.Rig rather than the concrete type, and the assertion below is what makes that safe rather than merely convenient: the released type must satisfy it or this package does not build, so the seam cannot drift away from the API it stands for. What it buys is that every path through NewSession, RestoreSession and bind is reachable from a test, which *rig.Rig is not — assembling one needs loops, primers and a model adapter that no unit test can supply, and 460 lines went untested behind exactly that.

type Option

type Option func(*Adapter)

Option configures an Adapter.

func WithBlockDecoder

func WithBlockDecoder(decode BlockDecoder) Option

WithBlockDecoder supplies the decoder an input command's body is read with.

type Rigs

type Rigs interface {
	// RigForCreate assembles the launcher a new session launches on.
	RigForCreate(context.Context, department.RigCreateRequest) (Launcher, error)

	// RigForRestore assembles the launcher a restored session relaunches on. The
	// UUID is Harness's identity for the session and is passed so an assembly can
	// refuse a session it has no state for.
	RigForRestore(context.Context, uuid.UUID, department.RigRestoreRequest) (Launcher, error)
}

Rigs resolves the assembled Rig one launch runs on.

IT IS PER-REQUEST BECAUSE FINDING H1 LEAVES NO ALTERNATIVE. A rig's workspace root and resource storage are fixed by rig.Define, and rig.NewSession accepts neither; department.CreateRequest carries a WorkspaceRoot that Host has just materialized and a storage namespace the session's objects must live under. The only place both can be honoured is the assembly, so the composition root assembles per launch. A shared rig is still expressible — return the same pointer — and the difference is then visible rather than assumed.

type UnsupportedCommandError

type UnsupportedCommandError struct {
	CommandID sessionwire.CommandID
	Kind      string
	Reason    string
}

UnsupportedCommandError reports an admitted command the released runtime command API cannot express.

func (*UnsupportedCommandError) Error

func (e *UnsupportedCommandError) Error() string

Jump to

Keyboard shortcuts

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